diff --git a/Nimble.xcodeproj/project.pbxproj b/Nimble.xcodeproj/project.pbxproj index b4dcf479c..d6a9ce775 100644 --- a/Nimble.xcodeproj/project.pbxproj +++ b/Nimble.xcodeproj/project.pbxproj @@ -99,7 +99,7 @@ 1F5DF1891BDCA0F500C3A531 /* RaisesException.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD1E1968AB07008ED995 /* RaisesException.swift */; }; 1F5DF18A1BDCA0F500C3A531 /* ThrowError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29EA59651B551EE6002D767E /* ThrowError.swift */; }; 1F5DF18B1BDCA0F500C3A531 /* Functional.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD251968AB07008ED995 /* Functional.swift */; }; - 1F5DF18C1BDCA0F500C3A531 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD261968AB07008ED995 /* Poll.swift */; }; + 1F5DF18C1BDCA0F500C3A531 /* Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD261968AB07008ED995 /* Async.swift */; }; 1F5DF18D1BDCA0F500C3A531 /* SourceLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD271968AB07008ED995 /* SourceLocation.swift */; }; 1F5DF18E1BDCA0F500C3A531 /* Stringers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD281968AB07008ED995 /* Stringers.swift */; }; 1F5DF18F1BDCA0F500C3A531 /* AsyncMatcherWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD2A1968AB07008ED995 /* AsyncMatcherWrapper.swift */; }; @@ -173,6 +173,9 @@ 1F9DB8FC1A74E793002E96AD /* ObjCBeEmptyTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 1F9DB8FA1A74E793002E96AD /* ObjCBeEmptyTest.m */; }; 1FB90098195EC4B8001D7FAE /* BeIdenticalToTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB90097195EC4B8001D7FAE /* BeIdenticalToTest.swift */; }; 1FB90099195EC4B8001D7FAE /* BeIdenticalToTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FB90097195EC4B8001D7FAE /* BeIdenticalToTest.swift */; }; + 1FC494AA1C29CBA40010975C /* NimbleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC494A91C29CBA40010975C /* NimbleEnvironment.swift */; }; + 1FC494AB1C29CBA40010975C /* NimbleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC494A91C29CBA40010975C /* NimbleEnvironment.swift */; }; + 1FC494AC1C29CBA40010975C /* NimbleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC494A91C29CBA40010975C /* NimbleEnvironment.swift */; }; 1FD8CD2E1968AB07008ED995 /* AssertionRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD051968AB07008ED995 /* AssertionRecorder.swift */; }; 1FD8CD2F1968AB07008ED995 /* AssertionRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD051968AB07008ED995 /* AssertionRecorder.swift */; }; 1FD8CD301968AB07008ED995 /* AdapterProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD061968AB07008ED995 /* AdapterProtocols.swift */; }; @@ -229,8 +232,8 @@ 1FD8CD651968AB07008ED995 /* NMBExceptionCapture.h in Headers */ = {isa = PBXBuildFile; fileRef = 1FD8CD221968AB07008ED995 /* NMBExceptionCapture.h */; settings = {ATTRIBUTES = (Public, ); }; }; 1FD8CD661968AB07008ED995 /* NMBExceptionCapture.m in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD231968AB07008ED995 /* NMBExceptionCapture.m */; }; 1FD8CD671968AB07008ED995 /* NMBExceptionCapture.m in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD231968AB07008ED995 /* NMBExceptionCapture.m */; }; - 1FD8CD6A1968AB07008ED995 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD261968AB07008ED995 /* Poll.swift */; }; - 1FD8CD6B1968AB07008ED995 /* Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD261968AB07008ED995 /* Poll.swift */; }; + 1FD8CD6A1968AB07008ED995 /* Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD261968AB07008ED995 /* Async.swift */; }; + 1FD8CD6B1968AB07008ED995 /* Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD261968AB07008ED995 /* Async.swift */; }; 1FD8CD701968AB07008ED995 /* AsyncMatcherWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD2A1968AB07008ED995 /* AsyncMatcherWrapper.swift */; }; 1FD8CD711968AB07008ED995 /* AsyncMatcherWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD2A1968AB07008ED995 /* AsyncMatcherWrapper.swift */; }; 1FD8CD741968AB07008ED995 /* MatcherFunc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD8CD2C1968AB07008ED995 /* MatcherFunc.swift */; }; @@ -398,6 +401,7 @@ 1F925F10195C190B00ED456B /* BeGreaterThanOrEqualToTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BeGreaterThanOrEqualToTest.swift; sourceTree = ""; }; 1F9DB8FA1A74E793002E96AD /* ObjCBeEmptyTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ObjCBeEmptyTest.m; sourceTree = ""; }; 1FB90097195EC4B8001D7FAE /* BeIdenticalToTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BeIdenticalToTest.swift; sourceTree = ""; }; + 1FC494A91C29CBA40010975C /* NimbleEnvironment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NimbleEnvironment.swift; sourceTree = ""; }; 1FD8CD051968AB07008ED995 /* AssertionRecorder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssertionRecorder.swift; sourceTree = ""; }; 1FD8CD061968AB07008ED995 /* AdapterProtocols.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdapterProtocols.swift; sourceTree = ""; }; 1FD8CD071968AB07008ED995 /* NimbleXCTestHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NimbleXCTestHandler.swift; sourceTree = ""; }; @@ -427,7 +431,7 @@ 1FD8CD221968AB07008ED995 /* NMBExceptionCapture.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = NMBExceptionCapture.h; sourceTree = ""; }; 1FD8CD231968AB07008ED995 /* NMBExceptionCapture.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NMBExceptionCapture.m; sourceTree = ""; }; 1FD8CD251968AB07008ED995 /* Functional.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Functional.swift; sourceTree = ""; }; - 1FD8CD261968AB07008ED995 /* Poll.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Poll.swift; sourceTree = ""; }; + 1FD8CD261968AB07008ED995 /* Async.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Async.swift; sourceTree = ""; }; 1FD8CD271968AB07008ED995 /* SourceLocation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SourceLocation.swift; sourceTree = ""; }; 1FD8CD281968AB07008ED995 /* Stringers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Stringers.swift; sourceTree = ""; }; 1FD8CD2A1968AB07008ED995 /* AsyncMatcherWrapper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AsyncMatcherWrapper.swift; sourceTree = ""; }; @@ -622,6 +626,7 @@ 1FD8CD061968AB07008ED995 /* AdapterProtocols.swift */, 1FD8CD071968AB07008ED995 /* NimbleXCTestHandler.swift */, 1FDBD8661AF8A4FF0089F27B /* AssertionDispatcher.swift */, + 1FC494A91C29CBA40010975C /* NimbleEnvironment.swift */, ); path = Adapters; sourceTree = ""; @@ -670,7 +675,7 @@ isa = PBXGroup; children = ( 1FD8CD251968AB07008ED995 /* Functional.swift */, - 1FD8CD261968AB07008ED995 /* Poll.swift */, + 1FD8CD261968AB07008ED995 /* Async.swift */, 1FD8CD271968AB07008ED995 /* SourceLocation.swift */, 1FD8CD281968AB07008ED995 /* Stringers.swift */, ); @@ -1000,6 +1005,7 @@ 1FD8CD4C1968AB07008ED995 /* BeLessThan.swift in Sources */, 1FD8CD461968AB07008ED995 /* BeGreaterThan.swift in Sources */, 1FD8CD301968AB07008ED995 /* AdapterProtocols.swift in Sources */, + 1FC494AA1C29CBA40010975C /* NimbleEnvironment.swift in Sources */, 1FD8CD5E1968AB07008ED995 /* RaisesException.swift in Sources */, 1FD8CD561968AB07008ED995 /* Contain.swift in Sources */, 1FD8CD481968AB07008ED995 /* BeGreaterThanOrEqualTo.swift in Sources */, @@ -1009,7 +1015,7 @@ 1FD8CD621968AB07008ED995 /* DSL.m in Sources */, 1FD8CD421968AB07008ED995 /* BeEmpty.swift in Sources */, 1FD8CD521968AB07008ED995 /* BeNil.swift in Sources */, - 1FD8CD6A1968AB07008ED995 /* Poll.swift in Sources */, + 1FD8CD6A1968AB07008ED995 /* Async.swift in Sources */, 1FD8CD581968AB07008ED995 /* EndWith.swift in Sources */, 1FD8CD5C1968AB07008ED995 /* MatcherProtocols.swift in Sources */, 1FD8CD761968AB07008ED995 /* ObjCMatcher.swift in Sources */, @@ -1108,13 +1114,14 @@ 1F5DF18F1BDCA0F500C3A531 /* AsyncMatcherWrapper.swift in Sources */, 1F5DF1711BDCA0F500C3A531 /* DSL+Wait.swift in Sources */, 1F5DF17D1BDCA0F500C3A531 /* BeGreaterThanOrEqualTo.swift in Sources */, + 1FC494AC1C29CBA40010975C /* NimbleEnvironment.swift in Sources */, 1F5DF18E1BDCA0F500C3A531 /* Stringers.swift in Sources */, 1F5DF1AD1BDCA16E00C3A531 /* NMBExceptionCapture.m in Sources */, 1F5DF16D1BDCA0F500C3A531 /* AdapterProtocols.swift in Sources */, 1F5DF17B1BDCA0F500C3A531 /* BeginWith.swift in Sources */, 1F5DF17E1BDCA0F500C3A531 /* BeIdenticalTo.swift in Sources */, 1F5DF17A1BDCA0F500C3A531 /* BeEmpty.swift in Sources */, - 1F5DF18C1BDCA0F500C3A531 /* Poll.swift in Sources */, + 1F5DF18C1BDCA0F500C3A531 /* Async.swift in Sources */, 1F5DF1821BDCA0F500C3A531 /* BeNil.swift in Sources */, 1F5DF1AC1BDCA16E00C3A531 /* DSL.m in Sources */, 1F5DF16F1BDCA0F500C3A531 /* AssertionDispatcher.swift in Sources */, @@ -1190,6 +1197,7 @@ 1FD8CD4D1968AB07008ED995 /* BeLessThan.swift in Sources */, 1FD8CD471968AB07008ED995 /* BeGreaterThan.swift in Sources */, 1FD8CD311968AB07008ED995 /* AdapterProtocols.swift in Sources */, + 1FC494AB1C29CBA40010975C /* NimbleEnvironment.swift in Sources */, 1FD8CD5F1968AB07008ED995 /* RaisesException.swift in Sources */, 1FD8CD571968AB07008ED995 /* Contain.swift in Sources */, 1FD8CD491968AB07008ED995 /* BeGreaterThanOrEqualTo.swift in Sources */, @@ -1199,7 +1207,7 @@ 1FD8CD631968AB07008ED995 /* DSL.m in Sources */, 1FD8CD431968AB07008ED995 /* BeEmpty.swift in Sources */, 1FD8CD531968AB07008ED995 /* BeNil.swift in Sources */, - 1FD8CD6B1968AB07008ED995 /* Poll.swift in Sources */, + 1FD8CD6B1968AB07008ED995 /* Async.swift in Sources */, 1FD8CD591968AB07008ED995 /* EndWith.swift in Sources */, 1FD8CD5D1968AB07008ED995 /* MatcherProtocols.swift in Sources */, 1FD8CD771968AB07008ED995 /* ObjCMatcher.swift in Sources */, diff --git a/Nimble/Adapters/AssertionRecorder.swift b/Nimble/Adapters/AssertionRecorder.swift index 4709942d6..a1615a71d 100644 --- a/Nimble/Adapters/AssertionRecorder.swift +++ b/Nimble/Adapters/AssertionRecorder.swift @@ -44,11 +44,12 @@ public class AssertionRecorder : AssertionHandler { /// /// @see AssertionHandler public func withAssertionHandler(tempAssertionHandler: AssertionHandler, closure: () throws -> Void) { - let oldRecorder = NimbleAssertionHandler + let environment = NimbleEnvironment.activeInstance + let oldRecorder = environment.assertionHandler let capturer = NMBExceptionCapture(handler: nil, finally: ({ - NimbleAssertionHandler = oldRecorder + environment.assertionHandler = oldRecorder })) - NimbleAssertionHandler = tempAssertionHandler + environment.assertionHandler = tempAssertionHandler capturer.tryBlock { try! closure() } @@ -65,7 +66,7 @@ public func withAssertionHandler(tempAssertionHandler: AssertionHandler, closure /// /// @see gatherFailingExpectations public func gatherExpectations(silently silently: Bool = false, closure: () -> Void) -> [AssertionRecord] { - let previousRecorder = NimbleAssertionHandler + let previousRecorder = NimbleEnvironment.activeInstance.assertionHandler let recorder = AssertionRecorder() let handlers: [AssertionHandler] diff --git a/Nimble/Adapters/NimbleEnvironment.swift b/Nimble/Adapters/NimbleEnvironment.swift new file mode 100644 index 000000000..51ae18685 --- /dev/null +++ b/Nimble/Adapters/NimbleEnvironment.swift @@ -0,0 +1,35 @@ +import Foundation + +/// "Global" state of Nimble is stored here. Only DSL functions should access / be aware of this +/// class' existance +internal class NimbleEnvironment { + static var activeInstance: NimbleEnvironment { + get { + let env = NSThread.currentThread().threadDictionary["NimbleEnvironment"] + if let env = env as? NimbleEnvironment { + return env + } else { + let newEnv = NimbleEnvironment() + self.activeInstance = newEnv + return newEnv + } + } + set { + NSThread.currentThread().threadDictionary["NimbleEnvironment"] = newValue + } + } + + // TODO: eventually migrate the global to this environment value + var assertionHandler: AssertionHandler { + get { return NimbleAssertionHandler } + set { NimbleAssertionHandler = newValue } + } + var awaiter: Awaiter + + init() { + awaiter = Awaiter( + waitLock: AssertionWaitLock(), + asyncQueue: dispatch_get_main_queue(), + timeoutQueue: dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0)) + } +} \ No newline at end of file diff --git a/Nimble/DSL+Wait.swift b/Nimble/DSL+Wait.swift index 7214d2924..9729f5aad 100644 --- a/Nimble/DSL+Wait.swift +++ b/Nimble/DSL+Wait.swift @@ -1,32 +1,72 @@ import Foundation +private enum ErrorResult { + case Exception(NSException) + case Error(ErrorType) + case None +} + /// Only classes, protocols, methods, properties, and subscript declarations can be /// bridges to Objective-C via the @objc keyword. This class encapsulates callback-style /// asynchronous waiting logic so that it may be called from Objective-C and Swift. internal class NMBWait: NSObject { - internal class func until(timeout timeout: NSTimeInterval, file: String = __FILE__, line: UInt = __LINE__, action: (() -> Void) -> Void) -> Void { - var completed = false - var token: dispatch_once_t = 0 - let result = pollBlock(pollInterval: 0.01, timeoutInterval: timeout) { - dispatch_once(&token) { + internal class func until( + timeout timeout: NSTimeInterval, + file: String = __FILE__, + line: UInt = __LINE__, + action: (() -> Void) -> Void) -> Void { + return throwableUntil(timeout: timeout, file: file, line: line) { (done: () -> Void) throws -> Void in + action() { done() } + } + } + + // Using a throwable closure makes this method not objc compatible. + internal class func throwableUntil( + timeout timeout: NSTimeInterval, + file: String = __FILE__, + line: UInt = __LINE__, + action: (() -> Void) throws -> Void) -> Void { + let awaiter = NimbleEnvironment.activeInstance.awaiter + let leeway = timeout / 2.0 + let result = awaiter.performBlock { (done: (ErrorResult) -> Void) throws -> Void in dispatch_async(dispatch_get_main_queue()) { - action() { completed = true } + let capture = NMBExceptionCapture( + handler: ({ exception in + done(.Exception(exception)) + }), + finally: ({ }) + ) + capture.tryBlock { + do { + try action() { + done(.None) + } + } catch let e { + done(.Error(e)) + } + } } + }.timeout(timeout, forcefullyAbortTimeout: leeway).wait("waitUntil(...)", file: file, line: line) + + switch result { + case .Incomplete: internalError("Reached .Incomplete state for waitUntil(...).") + case .BlockedRunLoop: + fail(blockedRunLoopErrorMessageFor("-waitUntil()", leeway: leeway), + file: file, line: line) + case .TimedOut: + let pluralize = (timeout == 1 ? "" : "s") + fail("Waited more than \(timeout) second\(pluralize)", file: file, line: line) + case let .RaisedException(exception): + fail("Unexpected exception raised: \(exception)") + case let .ErrorThrown(error): + fail("Unexpected error thrown: \(error)") + case .Completed(.Exception(let exception)): + fail("Unexpected exception raised: \(exception)") + case .Completed(.Error(let error)): + fail("Unexpected error thrown: \(error)") + case .Completed(.None): // success + break } - return completed - } - switch (result) { - case .Failure: - let pluralize = (timeout == 1 ? "" : "s") - fail("Waited more than \(timeout) second\(pluralize)", file: file, line: line) - case .Timeout: - fail("Stall on main thread - too much enqueued on main run loop before waitUntil executes.", file: file, line: line) - case let .ErrorThrown(error): - // Technically, we can never reach this via a public API call - fail("Unexpected error thrown: \(error)", file: file, line: line) - case .Success: - break - } } @objc(untilFile:line:action:) @@ -35,9 +75,17 @@ internal class NMBWait: NSObject { } } -/// Wait asynchronously until the done closure is called. +internal func blockedRunLoopErrorMessageFor(fnName: String, leeway: NSTimeInterval) -> String { + return "\(fnName) timed out but was unable to run the timeout handler because the main thread is unresponsive (\(leeway) seconds is allow after the wait times out). Conditions that may cause this include processing blocking IO on the main thread, calls to sleep(), deadlocks, and synchronous IPC. Nimble forcefully stopped run loop which may cause future failures in test run." +} + +/// Wait asynchronously until the done closure is called or the timeout has been reached. /// -/// This will advance the run loop. +/// @discussion +/// Call the done() closure to indicate the waiting has completed. +/// +/// 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. public func waitUntil(timeout timeout: NSTimeInterval = 1, file: String = __FILE__, line: UInt = __LINE__, action: (() -> Void) -> Void) -> Void { NMBWait.until(timeout: timeout, file: file, line: line, action: action) } \ No newline at end of file diff --git a/Nimble/DSL.swift b/Nimble/DSL.swift index 98c60baaf..e68bb567e 100644 --- a/Nimble/DSL.swift +++ b/Nimble/DSL.swift @@ -1,3 +1,5 @@ +import Foundation + /// Make an expectation on a given actual value. The value given is lazily evaluated. public func expect(@autoclosure(escaping) expression: () throws -> T?, file: String = __FILE__, line: UInt = __LINE__) -> Expectation { return Expectation( @@ -18,7 +20,8 @@ public func expect(file: String = __FILE__, line: UInt = __LINE__, expression /// Always fails the test with a message and a specified location. public func fail(message: String, location: SourceLocation) { - NimbleAssertionHandler.assert(false, message: FailureMessage(stringValue: message), location: location) + let handler = NimbleEnvironment.activeInstance.assertionHandler + handler.assert(false, message: FailureMessage(stringValue: message), location: location) } /// Always fails the test with a message. @@ -30,3 +33,28 @@ public func fail(message: String, file: String = __FILE__, line: UInt = __LINE__ public func fail(file: String = __FILE__, line: UInt = __LINE__) { fail("fail() always fails", file: file, line: line) } + +/// Like Swift's precondition(), but raises NSExceptions instead of sigaborts +internal func nimblePrecondition( + @autoclosure expr: () -> Bool, + @autoclosure _ name: () -> String, + @autoclosure _ message: () -> String) -> Bool { + let result = expr() + if !result { + let e = NSException( + name: name(), + reason: message(), + userInfo: nil) + e.raise() + } + return result +} + +@noreturn +internal func internalError(msg: String, file: String = __FILE__, line: UInt = __LINE__) { + fatalError( + "Nimble Bug Found: \(msg) at \(file):\(line).\n" + + "Please file a bug to Nimble: https://github.com/Quick/Nimble/issues with the " + + "code snippet that caused this error." + ) +} \ No newline at end of file diff --git a/Nimble/Expectation.swift b/Nimble/Expectation.swift index dcf1a92a4..520902d07 100644 --- a/Nimble/Expectation.swift +++ b/Nimble/Expectation.swift @@ -36,7 +36,8 @@ public struct Expectation { let expression: Expression public func verify(pass: Bool, _ message: FailureMessage) { - NimbleAssertionHandler.assert(pass, message: message, location: expression.location) + let handler = NimbleEnvironment.activeInstance.assertionHandler + handler.assert(pass, message: message, location: expression.location) } /// Tests the actual value using a matcher to match. diff --git a/Nimble/Matchers/MatcherProtocols.swift b/Nimble/Matchers/MatcherProtocols.swift index 10f5b4e5a..c4213f878 100644 --- a/Nimble/Matchers/MatcherProtocols.swift +++ b/Nimble/Matchers/MatcherProtocols.swift @@ -47,9 +47,9 @@ private let dateFormatter: NSDateFormatter = { let formatter = NSDateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSS" formatter.locale = NSLocale(localeIdentifier: "en_US_POSIX") - + return formatter - }() +}() extension NSDate: NMBDoubleConvertible { public var doubleValue: CDouble { diff --git a/Nimble/Utils/Async.swift b/Nimble/Utils/Async.swift new file mode 100644 index 000000000..86297624a --- /dev/null +++ b/Nimble/Utils/Async.swift @@ -0,0 +1,354 @@ +import Foundation +import Dispatch + +private let timeoutLeeway: UInt64 = NSEC_PER_MSEC +private let pollLeeway: UInt64 = NSEC_PER_MSEC + +/// Stores debugging information about callers +internal struct WaitingInfo: CustomStringConvertible { + let name: String + let file: String + let lineNumber: UInt + + var description: String { + return "\(name) at \(file):\(lineNumber)" + } +} + +internal protocol WaitLock { + func acquireWaitingLock(fnName: String, file: String, line: UInt) + func releaseWaitingLock() + func isWaitingLocked() -> Bool +} + +internal class AssertionWaitLock: WaitLock { + private var currentWaiter: WaitingInfo? = nil + init() { } + + func acquireWaitingLock(fnName: String, file: String, line: UInt) { + let info = WaitingInfo(name: fnName, file: file, lineNumber: line) + nimblePrecondition( + NSThread.isMainThread(), + "InvalidNimbleAPIUsage", + "\(fnName) can only run on the main thread." + ) + nimblePrecondition( + currentWaiter == nil, + "InvalidNimbleAPIUsage", + "Nested async expectations are not allowed to avoid creating flaky tests.\n\n" + + "The call to\n\t\(info)\n" + + "triggered this exception because\n\t\(currentWaiter!)\n" + + "is currently managing the main run loop." + ) + currentWaiter = info + } + + func isWaitingLocked() -> Bool { + return currentWaiter != nil + } + + func releaseWaitingLock() { + currentWaiter = nil + } +} + +internal enum AwaitResult { + /// Incomplete indicates None (aka - this value hasn't been fulfilled yet) + case Incomplete + /// TimedOut indicates the result reached its defined timeout limit before returning + case TimedOut + /// BlockedRunLoop indicates the main runloop is too busy processing other blocks to trigger + /// the timeout code. + /// + /// This may also mean the async code waiting upon may have never actually ran within the + /// required time because other timers & sources are running on the main run loop. + case BlockedRunLoop + /// The async block successfully executed and returned a given result + case Completed(T) + /// When a Swift Error is thrown + case ErrorThrown(ErrorType) + /// When an Objective-C Exception is raised + case RaisedException(NSException) + + func isIncomplete() -> Bool { + switch self { + case .Incomplete: return true + default: return false + } + } + + func isCompleted() -> Bool { + switch self { + case .Completed(_): return true + default: return false + } + } +} + +/// Holds the resulting value from an asynchronous expectation. +/// This class is thread-safe at receiving an "response" to this promise. +internal class AwaitPromise { + private(set) internal var asyncResult: AwaitResult = .Incomplete + private var signal: dispatch_semaphore_t + + init() { + signal = dispatch_semaphore_create(1) + } + + /// Resolves the promise with the given result if it has not been resolved. Repeated calls to + /// this method will resolve in a no-op. + /// + /// @returns a Bool that indicates if the async result was accepted or rejected because another + /// value was recieved first. + func resolveResult(result: AwaitResult) -> Bool { + if dispatch_semaphore_wait(signal, DISPATCH_TIME_NOW) == 0 { + self.asyncResult = result + return true + } else { + return false + } + } +} + +internal struct AwaitTrigger { + let timeoutSource: dispatch_source_t + let actionSource: dispatch_source_t? + let start: () throws -> Void +} + +/// Factory for building fully configured AwaitPromises and waiting for their results. +/// +/// This factory stores all the state for an async expectation so that Await doesn't +/// doesn't have to manage it. +internal class AwaitPromiseBuilder { + let awaiter: Awaiter + let waitLock: WaitLock + let trigger: AwaitTrigger + let promise: AwaitPromise + + internal init( + awaiter: Awaiter, + waitLock: WaitLock, + promise: AwaitPromise, + trigger: AwaitTrigger) { + self.awaiter = awaiter + self.waitLock = waitLock + self.promise = promise + self.trigger = trigger + } + + func timeout(timeoutInterval: NSTimeInterval, forcefullyAbortTimeout: NSTimeInterval) -> Self { + // = Discussion = + // + // There's a lot of technical decisions here that is useful to elaborate on. This is + // definitely more lower-level than the previous NSRunLoop based implementation. + // + // + // Why Dispatch Source? + // + // + // We're using a dispatch source to have better control of the run loop behavior. + // A timer source gives us deferred-timing control without having to rely as much on + // a run loop's traditional dispatching machinery (eg - NSTimers, DefaultRunLoopMode, etc.) + // which is ripe for getting corrupted by application code. + // + // And unlike dispatch_async(), we can control how likely our code gets prioritized to + // executed (see leeway parameter) + DISPATCH_TIMER_STRICT. + // + // This timer is assumed to run on the HIGH priority queue to ensure it maintains the + // highest priority over normal application / test code when possible. + // + // + // Run Loop Management + // + // In order to properly interrupt the waiting behavior performed by this factory class, + // this timer stops the main run loop to tell the waiter code that the result should be + // checked. + // + // In addition, stopping the run loop is used to halt code executed on the main run loop. + dispatch_source_set_timer( + trigger.timeoutSource, + dispatch_time(DISPATCH_TIME_NOW, Int64(timeoutInterval * Double(NSEC_PER_SEC))), + DISPATCH_TIME_FOREVER, + timeoutLeeway + ) + dispatch_source_set_event_handler(trigger.timeoutSource) { + guard self.promise.asyncResult.isIncomplete() else { return } + let timedOutSem = dispatch_semaphore_create(0) + let semTimedOutOrBlocked = dispatch_semaphore_create(0) + dispatch_semaphore_signal(semTimedOutOrBlocked) + let runLoop = CFRunLoopGetMain() + CFRunLoopPerformBlock(runLoop, kCFRunLoopDefaultMode) { + if dispatch_semaphore_wait(semTimedOutOrBlocked, DISPATCH_TIME_NOW) == 0 { + dispatch_semaphore_signal(timedOutSem) + dispatch_semaphore_signal(semTimedOutOrBlocked) + if self.promise.resolveResult(.TimedOut) { + CFRunLoopStop(CFRunLoopGetMain()) + } + } + } + // potentially interrupt blocking code on run loop to let timeout code run + CFRunLoopStop(runLoop) + let now = dispatch_time(DISPATCH_TIME_NOW, Int64(forcefullyAbortTimeout * Double(NSEC_PER_SEC))) + let didNotTimeOut = dispatch_semaphore_wait(timedOutSem, now) != 0 + let timeoutWasNotTriggered = dispatch_semaphore_wait(semTimedOutOrBlocked, 0) == 0 + if didNotTimeOut && timeoutWasNotTriggered { + if self.promise.resolveResult(.BlockedRunLoop) { + CFRunLoopStop(CFRunLoopGetMain()) + } + } + } + return self + } + + /// Blocks for an asynchronous result. + /// + /// @discussion + /// This function must be executed on the main thread and cannot be nested. This is because + /// this function (and it's related methods) coordinate through the main run loop. Tampering + /// with the run loop can cause undesireable behavior. + /// + /// This method will return an AwaitResult in the following cases: + /// + /// - The main run loop is blocked by other operations and the async expectation cannot be + /// be stopped. + /// - The async expectation timed out + /// - The async expectation succeeded + /// - The async expectation raised an unexpected exception (objc) + /// - The async expectation raised an unexpected error (swift) + /// + /// The returned AwaitResult will NEVER be .Incomplete. + func wait(fnName: String = __FUNCTION__, file: String = __FILE__, line: UInt = __LINE__) -> AwaitResult { + waitLock.acquireWaitingLock( + fnName, + file: file, + line: line) + + let capture = NMBExceptionCapture(handler: ({ exception in + self.promise.resolveResult(.RaisedException(exception)) + }), finally: ({ + self.waitLock.releaseWaitingLock() + })) + capture.tryBlock { + do { + try self.trigger.start() + } catch let error { + self.promise.resolveResult(.ErrorThrown(error)) + } + dispatch_resume(self.trigger.timeoutSource) + while self.promise.asyncResult.isIncomplete() { + // Stopping the run loop does not work unless we run only 1 mode + NSRunLoop.currentRunLoop().runMode(NSDefaultRunLoopMode, beforeDate: NSDate.distantFuture()) + } + dispatch_suspend(self.trigger.timeoutSource) + dispatch_source_cancel(self.trigger.timeoutSource) + if let asyncSource = self.trigger.actionSource { + dispatch_source_cancel(asyncSource) + } + } + + return promise.asyncResult + } +} + +internal class Awaiter { + let waitLock: WaitLock + let timeoutQueue: dispatch_queue_t + let asyncQueue: dispatch_queue_t + + internal init( + waitLock: WaitLock, + asyncQueue: dispatch_queue_t, + timeoutQueue: dispatch_queue_t) { + self.waitLock = waitLock + self.asyncQueue = asyncQueue + self.timeoutQueue = timeoutQueue + } + + private func createTimerSource(queue: dispatch_queue_t) -> dispatch_source_t { + return dispatch_source_create( + DISPATCH_SOURCE_TYPE_TIMER, + 0, + DISPATCH_TIMER_STRICT, + queue + ) + } + + func performBlock( + closure: ((T) -> Void) throws -> Void) -> AwaitPromiseBuilder { + let promise = AwaitPromise() + let timeoutSource = createTimerSource(timeoutQueue) + var completionCount = 0 + let trigger = AwaitTrigger(timeoutSource: timeoutSource, actionSource: nil) { + try closure() { + completionCount += 1 + nimblePrecondition( + completionCount < 2, + "InvalidNimbleAPIUsage", + "Done closure's was called multiple times. waitUntil(..) expects its " + + "completion closure to only be called once.") + if promise.resolveResult(.Completed($0)) { + CFRunLoopStop(CFRunLoopGetMain()) + } + } + } + + return AwaitPromiseBuilder( + awaiter: self, + waitLock: waitLock, + promise: promise, + trigger: trigger) + } + + func poll(pollInterval: NSTimeInterval, closure: () throws -> T?) -> AwaitPromiseBuilder { + let promise = AwaitPromise() + let timeoutSource = createTimerSource(timeoutQueue) + let asyncSource = createTimerSource(asyncQueue) + let trigger = AwaitTrigger(timeoutSource: timeoutSource, actionSource: asyncSource) { + let interval = UInt64(pollInterval * Double(NSEC_PER_SEC)) + dispatch_source_set_timer(asyncSource, DISPATCH_TIME_NOW, interval, pollLeeway) + dispatch_source_set_event_handler(asyncSource) { + do { + if let result = try closure() { + if promise.resolveResult(.Completed(result)) { + CFRunLoopStop(CFRunLoopGetCurrent()) + } + } + } catch let error { + if promise.resolveResult(.ErrorThrown(error)) { + CFRunLoopStop(CFRunLoopGetCurrent()) + } + } + } + dispatch_resume(asyncSource) + } + + return AwaitPromiseBuilder( + awaiter: self, + waitLock: waitLock, + promise: promise, + trigger: trigger) + } +} + +internal func pollBlock( + pollInterval pollInterval: NSTimeInterval, + timeoutInterval: NSTimeInterval, + file: String, + line: UInt, + fnName: String = __FUNCTION__, + expression: () throws -> Bool) -> AwaitResult { + let awaiter = NimbleEnvironment.activeInstance.awaiter + let result = awaiter.poll(pollInterval) { () throws -> Bool? in + do { + if try expression() { + return true + } + return nil + } catch let error { + throw error + } + }.timeout(timeoutInterval, forcefullyAbortTimeout: timeoutInterval / 2.0).wait(fnName, file: file, line: line) + + return result +} \ No newline at end of file diff --git a/Nimble/Utils/Poll.swift b/Nimble/Utils/Poll.swift deleted file mode 100644 index 603d11105..000000000 --- a/Nimble/Utils/Poll.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Foundation - -internal enum PollResult : BooleanType { - case Success, Failure, Timeout - case ErrorThrown(ErrorType) - - var boolValue : Bool { - switch (self) { - case .Success: - return true - default: - return false - } - } -} - -internal class RunPromise { - var token: dispatch_once_t = 0 - var didFinish = false - var didFail = false - - init() {} - - func succeed() { - dispatch_once(&self.token) { - self.didFinish = false - } - } - - func fail(block: () -> Void) { - dispatch_once(&self.token) { - self.didFail = true - block() - } - } -} - -let killQueue = dispatch_queue_create("nimble.waitUntil.queue", DISPATCH_QUEUE_SERIAL) - -internal func stopRunLoop(runLoop: NSRunLoop, delay: NSTimeInterval) -> RunPromise { - let promise = RunPromise() - let killTimeOffset = Int64(CDouble(delay) * CDouble(NSEC_PER_SEC)) - let killTime = dispatch_time(DISPATCH_TIME_NOW, killTimeOffset) - dispatch_after(killTime, killQueue) { - promise.fail { - CFRunLoopStop(runLoop.getCFRunLoop()) - } - } - return promise -} - -internal func pollBlock(pollInterval pollInterval: NSTimeInterval, timeoutInterval: NSTimeInterval, expression: () throws -> Bool) -> PollResult { - let runLoop = NSRunLoop.mainRunLoop() - - let promise = stopRunLoop(runLoop, delay: min(timeoutInterval, 0.2)) - - let startDate = NSDate() - - // trigger run loop to make sure enqueued tasks don't block our assertion polling - // the stop run loop task above will abort us if necessary - runLoop.runUntilDate(startDate) - dispatch_sync(killQueue) { - promise.succeed() - } - - if promise.didFail { - return .Timeout - } - - var pass = false - do { - repeat { - pass = try expression() - if pass { - break - } - - let runDate = NSDate().dateByAddingTimeInterval(pollInterval) - runLoop.runUntilDate(runDate) - } while(NSDate().timeIntervalSinceDate(startDate) < timeoutInterval) - } catch let error { - return .ErrorThrown(error) - } - - return pass ? .Success : .Failure -} diff --git a/Nimble/Wrappers/AsyncMatcherWrapper.swift b/Nimble/Wrappers/AsyncMatcherWrapper.swift index 2d1a15b1a..87b3e431e 100644 --- a/Nimble/Wrappers/AsyncMatcherWrapper.swift +++ b/Nimble/Wrappers/AsyncMatcherWrapper.swift @@ -13,35 +13,56 @@ internal struct AsyncMatcherWrapper: Ma func matches(actualExpression: Expression, failureMessage: FailureMessage) -> Bool { let uncachedExpression = actualExpression.withoutCaching() - let result = pollBlock(pollInterval: pollInterval, timeoutInterval: timeoutInterval) { - try self.fullMatcher.matches(uncachedExpression, failureMessage: failureMessage) + let fnName = "expect(...).toEventually(...)" + let result = pollBlock( + pollInterval: pollInterval, + timeoutInterval: timeoutInterval, + file: actualExpression.location.file, + line: actualExpression.location.line, + fnName: fnName) { + try self.fullMatcher.matches(uncachedExpression, failureMessage: failureMessage) } switch (result) { - case .Success: return true - case .Failure: return false + case let .Completed(isSuccessful): return isSuccessful + case .TimedOut: return false case let .ErrorThrown(error): failureMessage.actualValue = "an unexpected error thrown: <\(error)>" return false - case .Timeout: - failureMessage.postfixMessage += " (Stall on main thread)." + case let .RaisedException(exception): + failureMessage.actualValue = "an unexpected exception thrown: <\(exception)>" return false + case .BlockedRunLoop: + failureMessage.postfixMessage += " (timed out, but main thread was unresponsive)." + return false + case .Incomplete: + internalError("Reached .Incomplete state for toEventually(...).") } } func doesNotMatch(actualExpression: Expression, failureMessage: FailureMessage) -> Bool { let uncachedExpression = actualExpression.withoutCaching() - let result = pollBlock(pollInterval: pollInterval, timeoutInterval: timeoutInterval) { - try self.fullMatcher.doesNotMatch(uncachedExpression, failureMessage: failureMessage) + let result = pollBlock( + pollInterval: pollInterval, + timeoutInterval: timeoutInterval, + file: actualExpression.location.file, + line: actualExpression.location.line, + fnName: "expect(...).toEventuallyNot(...)") { + try self.fullMatcher.doesNotMatch(uncachedExpression, failureMessage: failureMessage) } switch (result) { - case .Success: return true - case .Failure: return false + case let .Completed(isSuccessful): return isSuccessful + case .TimedOut: return false case let .ErrorThrown(error): failureMessage.actualValue = "an unexpected error thrown: <\(error)>" return false - case .Timeout: - failureMessage.postfixMessage += " (Stall on main thread)." + case let .RaisedException(exception): + failureMessage.actualValue = "an unexpected exception thrown: <\(exception)>" + return false + case .BlockedRunLoop: + failureMessage.postfixMessage += " (timed out, but main thread was unresponsive)." return false + case .Incomplete: + internalError("Reached .Incomplete state for toEventuallyNot(...).") } } } @@ -52,6 +73,10 @@ private let toEventuallyRequiresClosureError = FailureMessage(stringValue: "expe extension Expectation { /// Tests the actual value using a matcher to match by checking continuously /// at each pollInterval until the timeout is reached. + /// + /// @discussion + /// 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. public func toEventually(matcher: U, timeout: NSTimeInterval = 1, pollInterval: NSTimeInterval = 0.01, description: String? = nil) { if expression.isClosure { let (pass, msg) = expressionMatches( @@ -71,6 +96,10 @@ extension Expectation { /// Tests the actual value using a matcher to not match by checking /// continuously at each pollInterval until the timeout is reached. + /// + /// @discussion + /// 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. public func toEventuallyNot(matcher: U, timeout: NSTimeInterval = 1, pollInterval: NSTimeInterval = 0.01, description: String? = nil) { if expression.isClosure { let (pass, msg) = expressionDoesNotMatch( @@ -92,6 +121,10 @@ extension Expectation { /// continuously at each pollInterval until the timeout is reached. /// /// Alias of toEventuallyNot() + /// + /// @discussion + /// 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. public func toNotEventually(matcher: U, timeout: NSTimeInterval = 1, pollInterval: NSTimeInterval = 0.01, description: String? = nil) { return toEventuallyNot(matcher, timeout: timeout, pollInterval: pollInterval, description: description) } diff --git a/Nimble/objc/NMBExceptionCapture.h b/Nimble/objc/NMBExceptionCapture.h index 8be4a5a69..7e5fb07c3 100644 --- a/Nimble/objc/NMBExceptionCapture.h +++ b/Nimble/objc/NMBExceptionCapture.h @@ -1,4 +1,5 @@ #import +#import @interface NMBExceptionCapture : NSObject @@ -6,3 +7,5 @@ - (void)tryBlock:(void(^)())unsafeBlock; @end + +typedef void(^NMBSourceCallbackBlock)(BOOL successful); diff --git a/Nimble/objc/NMBExceptionCapture.m b/Nimble/objc/NMBExceptionCapture.m index d19d5d973..48f573929 100644 --- a/Nimble/objc/NMBExceptionCapture.m +++ b/Nimble/objc/NMBExceptionCapture.m @@ -32,4 +32,4 @@ - (void)tryBlock:(void(^)())unsafeBlock { } } -@end +@end \ No newline at end of file diff --git a/NimbleTests/AsynchronousTest.swift b/NimbleTests/AsynchronousTest.swift index 754636635..0fa7aa4d0 100644 --- a/NimbleTests/AsynchronousTest.swift +++ b/NimbleTests/AsynchronousTest.swift @@ -1,6 +1,5 @@ import XCTest import Nimble -import Swift class AsyncTest: XCTestCase { let errorToThrow = NSError(domain: NSInternalInconsistencyException, code: 42, userInfo: nil) @@ -9,7 +8,7 @@ class AsyncTest: XCTestCase { throw errorToThrow } - func testAsyncTestingViaEventuallyPositiveMatches() { + func testToEventuallyPositiveMatches() { var value = 0 deferToMainQueue { value = 1 } expect { value }.toEventually(equal(1)) @@ -18,7 +17,7 @@ class AsyncTest: XCTestCase { expect { value }.toEventuallyNot(equal(1)) } - func testAsyncTestingViaEventuallyNegativeMatches() { + func testToEventuallyNegativeMatches() { let value = 0 failsWithErrorMessage("expected to eventually not equal <0>, got <0>") { expect { value }.toEventuallyNot(equal(0)) @@ -34,7 +33,7 @@ class AsyncTest: XCTestCase { } } - func testAsyncTestingViaWaitUntilPositiveMatches() { + func testWaitUntilPositiveMatches() { waitUntil { done in done() } @@ -45,17 +44,31 @@ class AsyncTest: XCTestCase { } } - func testAsyncTestingViaWaitUntilNegativeMatches() { + func testWaitUntilTimesOutIfNotCalled() { failsWithErrorMessage("Waited more than 1.0 second") { waitUntil(timeout: 1) { done in return } } + } + + func testWaitUntilTimesOutWhenExceedingItsTime() { + var waiting = true failsWithErrorMessage("Waited more than 0.01 seconds") { waitUntil(timeout: 0.01) { done in - NSThread.sleepForTimeInterval(0.1) - done() + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { + NSThread.sleepForTimeInterval(0.1) + done() + waiting = false + } } } + // "clear" runloop to ensure this test doesn't poison other tests + repeat { + NSRunLoop.mainRunLoop().runUntilDate(NSDate().dateByAddingTimeInterval(0.2)) + } while(waiting) + } + + func testWaitUntilNegativeMatches() { failsWithErrorMessage("expected to equal <2>, got <1>") { waitUntil { done in NSThread.sleepForTimeInterval(0.1) @@ -63,22 +76,77 @@ class AsyncTest: XCTestCase { done() } } - // "clear" runloop to ensure this test doesn't poison other tests - NSRunLoop.mainRunLoop().runUntilDate(NSDate().dateByAddingTimeInterval(0.2)) } func testWaitUntilDetectsStalledMainThreadActivity() { - dispatch_async(dispatch_get_main_queue()) { - NSThread.sleepForTimeInterval(2.0) + let msg = "-waitUntil() timed out but was unable to run the timeout handler because the main thread is unresponsive (0.5 seconds is allow after the wait times out). Conditions that may cause this include processing blocking IO on the main thread, calls to sleep(), deadlocks, and synchronous IPC. Nimble forcefully stopped run loop which may cause future failures in test run." + failsWithErrorMessage(msg) { + waitUntil(timeout: 1) { done in + NSThread.sleepForTimeInterval(5.0) + done() + } } + } - failsWithErrorMessage("Stall on main thread - too much enqueued on main run loop before waitUntil executes.") { - waitUntil { done in + func testCombiningAsyncWaitUntilAndToEventuallyIsNotAllowed() { + let referenceLine = __LINE__ + 9 + var msg = "Unexpected exception raised: Nested async expectations are not allowed " + msg += "to avoid creating flaky tests." + msg += "\n\n" + msg += "The call to\n\t" + msg += "expect(...).toEventually(...) at \(__FILE__):\(referenceLine + 7)\n" + msg += "triggered this exception because\n\t" + msg += "waitUntil(...) at \(__FILE__):\(referenceLine + 1)\n" + msg += "is currently managing the main run loop." + failsWithErrorMessage(msg) { // reference line + waitUntil(timeout: 2.0) { done in + var protected: Int = 0 + dispatch_async(dispatch_get_main_queue()) { + protected = 1 + } + + expect(protected).toEventually(equal(1)) done() } } + } - // "clear" runloop to ensure this test doesn't poison other tests - NSRunLoop.mainRunLoop().runUntilDate(NSDate().dateByAddingTimeInterval(2.0)) + func testWaitUntilErrorsIfDoneIsCalledMultipleTimes() { + waitUntil { done in + deferToMainQueue { + done() + } + } + + waitUntil { done in + deferToMainQueue { + done() + expect { + done() + }.to(raiseException(named: "InvalidNimbleAPIUsage")) + } + } + } + + func testWaitUntilMustBeInMainThread() { + var executedAsyncBlock: Bool = false + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { + expect { + waitUntil { done in done() } + }.to(raiseException(named: "InvalidNimbleAPIUsage")) + executedAsyncBlock = true + } + expect(executedAsyncBlock).toEventually(beTruthy()) + } + + func testToEventuallyMustBeInMainThread() { + var executedAsyncBlock: Bool = false + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { + expect { + expect(1).toEventually(equal(2)) + }.to(raiseException(named: "InvalidNimbleAPIUsage")) + executedAsyncBlock = true + } + expect(executedAsyncBlock).toEventually(beTruthy()) } } diff --git a/NimbleTests/objc/ObjCAsyncTest.m b/NimbleTests/objc/ObjCAsyncTest.m index ea95b250f..f052e7470 100644 --- a/NimbleTests/objc/ObjCAsyncTest.m +++ b/NimbleTests/objc/ObjCAsyncTest.m @@ -36,8 +36,10 @@ - (void)testAsyncCallback { expectFailureMessage(@"Waited more than 0.01 seconds", ^{ waitUntilTimeout(0.01, ^(void (^done)(void)){ - [NSThread sleepForTimeInterval:0.1]; - done(); + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + [NSThread sleepForTimeInterval:0.1]; + done(); + }); }); }); diff --git a/README.md b/README.md index 0fc37bd39..995de9f39 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,12 @@ dispatch_async(dispatch_get_main_queue(), ^{ expect(ocean).toEventually(contain(@"dolphins", @"whales")); ``` +Note: toEventually triggers its polls on the main thread. Blocking the main +thread will cause Nimble to stop the run loop. This can cause test pollution +for whatever incomplete code that was running on the main thread. Blocking the +main thread can be caused by blocking IO, calls to sleep(), deadlocks, and +synchronous IPC. + In the above example, `ocean` is constantly re-evaluated. If it ever contains dolphins and whales, the expectation passes. If `ocean` still doesn't contain them, even after being continuously re-evaluated for one @@ -374,6 +380,12 @@ waitUntilTimeout(10, ^(void (^done)(void)){ }); ``` +Note: waitUntil triggers its timeout code on the main thread. Blocking the main +thread will cause Nimble to stop the run loop to continue. This can cause test +pollution for whatever incomplete code that was running on the main thread. +Blocking the main thread can be caused by blocking IO, calls to sleep(), +deadlocks, and synchronous IPC. + ## Objective-C Support Nimble has full support for Objective-C. However, there are two things