diff --git a/IntegrationTests/TestSuites/Sources/PrimaryTests/UnitTestUtils.swift b/IntegrationTests/TestSuites/Sources/PrimaryTests/UnitTestUtils.swift index eefa7194e..07d20a4e1 100644 --- a/IntegrationTests/TestSuites/Sources/PrimaryTests/UnitTestUtils.swift +++ b/IntegrationTests/TestSuites/Sources/PrimaryTests/UnitTestUtils.swift @@ -101,3 +101,32 @@ func expectNotNil(_ value: T?, file: StaticString = #file, line: UInt = #line throw MessageError("Expect a non-nil value", file: file, line: line, column: column) } } + +class Expectation { + private(set) var isFulfilled: Bool = false + private let label: String + private let expectedFulfillmentCount: Int + private var fulfillmentCount: Int = 0 + + init(label: String, expectedFulfillmentCount: Int = 1) { + self.label = label + self.expectedFulfillmentCount = expectedFulfillmentCount + } + + func fulfill() { + assert(!isFulfilled, "Too many fulfillment (label: \(label)): expectedFulfillmentCount is \(expectedFulfillmentCount)") + fulfillmentCount += 1 + if fulfillmentCount == expectedFulfillmentCount { + isFulfilled = true + } + } + + static func wait(_ expectations: [Expectation]) { + var timer: JSTimer! + timer = JSTimer(millisecondsDelay: 5.0, isRepeating: true) { + guard expectations.allSatisfy(\.isFulfilled) else { return } + assert(timer != nil) + timer = nil + } + } +} diff --git a/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift b/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift index 66daeec0a..e93dfc1b6 100644 --- a/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift +++ b/IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift @@ -528,22 +528,80 @@ try test("Timer") { } var timer: JSTimer? -var promise: JSPromise<(), Never>? +var expectations: [Expectation] = [] try test("Promise") { + + let p1 = JSPromise.resolve(JSValue.null) + let exp1 = Expectation(label: "Promise.then testcase", expectedFulfillmentCount: 4) + p1.then { value in + try! expectEqual(value, .null) + exp1.fulfill() + return JSValue.number(1.0) + } + .then { value in + try! expectEqual(value, .number(1.0)) + exp1.fulfill() + return JSPromise.resolve(JSValue.boolean(true)) + } + .then { value in + try! expectEqual(value, .boolean(true)) + exp1.fulfill() + return JSValue.undefined + } + .catch { _ -> JSValue in + fatalError("Not fired due to no throw") + } + .finally { exp1.fulfill() } + + let exp2 = Expectation(label: "Promise.catch testcase", expectedFulfillmentCount: 4) + let p2 = JSPromise.reject(JSValue.boolean(false)) + p2.then { _ -> JSValue in + fatalError("Not fired due to no success") + } + .catch { reason in + try! expectEqual(reason, .boolean(false)) + exp2.fulfill() + return JSValue.boolean(true) + } + .then { value in + try! expectEqual(value, .boolean(true)) + exp2.fulfill() + return JSPromise.reject(JSValue.number(2.0)) + } + .catch { reason in + try! expectEqual(reason, .number(2.0)) + exp2.fulfill() + return JSValue.undefined + } + .finally { exp2.fulfill() } + + let start = JSDate().valueOf() let timeoutMilliseconds = 5.0 + let exp3 = Expectation(label: "Promise and Timer testcae", expectedFulfillmentCount: 2) - promise = JSPromise { resolve in + let p3 = JSPromise { resolve in timer = JSTimer(millisecondsDelay: timeoutMilliseconds) { - resolve() + exp3.fulfill() + resolve(.success(.undefined)) } } - promise!.then { + p3.then { _ in // verify that at least `timeoutMilliseconds` passed since the timer started try! expectEqual(start + timeoutMilliseconds <= JSDate().valueOf(), true) + exp3.fulfill() + return JSValue.undefined } + + let exp4 = Expectation(label: "Promise lifetime") + // Ensure that users don't need to manage JSPromise lifetime + JSPromise.resolve(JSValue.boolean(true)).then { _ in + exp4.fulfill() + return JSValue.undefined + } + expectations += [exp1, exp2, exp3, exp4] } try test("Error") { @@ -620,3 +678,5 @@ try test("Exception") { let errorObject3 = JSError(from: ageError as! JSValue) try expectNotNil(errorObject3) } + +Expectation.wait(expectations) diff --git a/Sources/JavaScriptKit/BasicObjects/JSPromise.swift b/Sources/JavaScriptKit/BasicObjects/JSPromise.swift index 02a6b27c0..5b0d47dd4 100644 --- a/Sources/JavaScriptKit/BasicObjects/JSPromise.swift +++ b/Sources/JavaScriptKit/BasicObjects/JSPromise.swift @@ -9,7 +9,7 @@ This doesn't 100% match the JavaScript API, as `then` overload with two callback It's impossible to unify success and failure types from both callbacks in a single returned promise without type erasure. You should chain `then` and `catch` in those cases to avoid type erasure. */ -public final class JSPromise: ConvertibleToJSValue, ConstructibleFromJSValue { +public final class JSPromise: JSBridgedClass { /// The underlying JavaScript `Promise` object. public let jsObject: JSObject @@ -18,17 +18,20 @@ public final class JSPromise: ConvertibleToJSValue, Constructi .object(jsObject) } + public static var constructor: JSFunction { + JSObject.global.Promise.function! + } + /// This private initializer assumes that the passed object is a JavaScript `Promise` - private init(unsafe object: JSObject) { + public init(unsafelyWrapping object: JSObject) { self.jsObject = object } /** Creates a new `JSPromise` instance from a given JavaScript `Promise` object. If `jsObject` is not an instance of JavaScript `Promise`, this initializer will return `nil`. */ - public init?(_ jsObject: JSObject) { - guard jsObject.isInstanceOf(JSObject.global.Promise.function!) else { return nil } - self.jsObject = jsObject + public convenience init?(_ jsObject: JSObject) { + self.init(from: jsObject) } /** Creates a new `JSPromise` instance from a given JavaScript `Promise` object. If `value` @@ -40,73 +43,10 @@ public final class JSPromise: ConvertibleToJSValue, Constructi return Self.init(jsObject) } - /** Schedules the `success` closure to be invoked on sucessful completion of `self`. - */ - public func then(success: @escaping () -> ()) { - let closure = JSOneshotClosure { _ in - success() - return .undefined - } - _ = jsObject.then!(closure) - } - - /** Schedules the `failure` closure to be invoked on either successful or rejected completion of - `self`. - */ - public func finally(successOrFailure: @escaping () -> ()) -> Self { - let closure = JSOneshotClosure { _ in - successOrFailure() - return .undefined - } - return .init(unsafe: jsObject.finally!(closure).object!) - } -} - -extension JSPromise where Success == (), Failure == Never { - /** Creates a new `JSPromise` instance from a given `resolver` closure. `resolver` takes - a closure that your code should call to resolve this `JSPromise` instance. - */ - public convenience init(resolver: @escaping (@escaping () -> ()) -> ()) { - let closure = JSOneshotClosure { arguments in - // The arguments are always coming from the `Promise` constructor, so we should be - // safe to assume their type here - resolver { arguments[0].function!() } - return .undefined - } - self.init(unsafe: JSObject.global.Promise.function!.new(closure)) - } -} - -extension JSPromise where Failure: ConvertibleToJSValue { - /** Creates a new `JSPromise` instance from a given `resolver` closure. `resolver` takes + /** Creates a new `JSPromise` instance from a given `resolver` closure. `resolver` takes two closure that your code should call to either resolve or reject this `JSPromise` instance. */ - public convenience init(resolver: @escaping (@escaping (Result) -> ()) -> ()) { - let closure = JSOneshotClosure { arguments in - // The arguments are always coming from the `Promise` constructor, so we should be - // safe to assume their type here - let resolve = arguments[0].function! - let reject = arguments[1].function! - - resolver { - switch $0 { - case .success: - resolve() - case let .failure(error): - reject(error.jsValue()) - } - } - return .undefined - } - self.init(unsafe: JSObject.global.Promise.function!.new(closure)) - } -} - -extension JSPromise where Success: ConvertibleToJSValue, Failure: JSError { - /** Creates a new `JSPromise` instance from a given `resolver` closure. `resolver` takes - a closure that your code should call to either resolve or reject this `JSPromise` instance. - */ - public convenience init(resolver: @escaping (@escaping (Result) -> ()) -> ()) { + public convenience init(resolver: @escaping (@escaping (Result) -> ()) -> ()) { let closure = JSOneshotClosure { arguments in // The arguments are always coming from the `Promise` constructor, so we should be // safe to assume their type here @@ -116,123 +56,67 @@ extension JSPromise where Success: ConvertibleToJSValue, Failure: JSError { resolver { switch $0 { case let .success(success): - resolve(success.jsValue()) + resolve(success) case let .failure(error): - reject(error.jsValue()) + reject(error) } } return .undefined } - self.init(unsafe: JSObject.global.Promise.function!.new(closure)) + self.init(unsafelyWrapping: Self.constructor.new(closure)) } -} -extension JSPromise where Success: ConstructibleFromJSValue { - /** Schedules the `success` closure to be invoked on sucessful completion of `self`. - */ - public func then( - success: @escaping (Success) -> (), - file: StaticString = #file, - line: Int = #line - ) { - let closure = JSOneshotClosure { arguments in - guard let result = Success.construct(from: arguments[0]) else { - fatalError("\(file):\(line): failed to unwrap success value for `then` callback") - } - success(result) - return .undefined - } - _ = jsObject.then!(closure) + public static func resolve(_ value: ConvertibleToJSValue) -> JSPromise { + self.init(unsafelyWrapping: Self.constructor.resolve!(value).object!) } - /** Returns a new promise created from chaining the current `self` promise with the `success` - closure invoked on sucessful completion of `self`. The returned promise will have a new - `Success` type equal to the return type of `success`. - */ - public func then( - success: @escaping (Success) -> ResultType, - file: StaticString = #file, - line: Int = #line - ) -> JSPromise { - let closure = JSOneshotClosure { arguments -> JSValue in - guard let result = Success.construct(from: arguments[0]) else { - fatalError("\(file):\(line): failed to unwrap success value for `then` callback") - } - return success(result).jsValue() - } - return .init(unsafe: jsObject.then!(closure).object!) + public static func reject(_ reason: ConvertibleToJSValue) -> JSPromise { + self.init(unsafelyWrapping: Self.constructor.reject!(reason).object!) } - /** Returns a new promise created from chaining the current `self` promise with the `success` - closure invoked on sucessful completion of `self`. The returned promise will have a new type - equal to the return type of `success`. + /** Schedules the `success` closure to be invoked on sucessful completion of `self`. */ - public func then( - success: @escaping (Success) -> JSPromise, - file: StaticString = #file, - line: Int = #line - ) -> JSPromise { - let closure = JSOneshotClosure { arguments -> JSValue in - guard let result = Success.construct(from: arguments[0]) else { - fatalError("\(file):\(line): failed to unwrap success value for `then` callback") - } - return success(result).jsValue() + @discardableResult + public func then(success: @escaping (JSValue) -> ConvertibleToJSValue) -> JSPromise { + let closure = JSOneshotClosure { + return success($0[0]).jsValue() } - return .init(unsafe: jsObject.then!(closure).object!) + return JSPromise(unsafelyWrapping: jsObject.then!(closure).object!) } -} -extension JSPromise where Failure: ConstructibleFromJSValue { - /** Returns a new promise created from chaining the current `self` promise with the `failure` - closure invoked on rejected completion of `self`. The returned promise will have a new `Success` - type equal to the return type of the callback, while the `Failure` type becomes `Never`. + /** Schedules the `success` closure to be invoked on sucessful completion of `self`. */ - public func `catch`( - failure: @escaping (Failure) -> ResultSuccess, - file: StaticString = #file, - line: Int = #line - ) -> JSPromise { - let closure = JSOneshotClosure { arguments -> JSValue in - guard let error = Failure.construct(from: arguments[0]) else { - fatalError("\(file):\(line): failed to unwrap error value for `catch` callback") - } - return failure(error).jsValue() + @discardableResult + public func then(success: @escaping (JSValue) -> ConvertibleToJSValue, + failure: @escaping (JSValue) -> ConvertibleToJSValue) -> JSPromise { + let successClosure = JSOneshotClosure { + return success($0[0]).jsValue() } - return .init(unsafe: jsObject.then!(JSValue.undefined, closure).object!) + let failureClosure = JSOneshotClosure { + return failure($0[0]).jsValue() + } + return JSPromise(unsafelyWrapping: jsObject.then!(successClosure, failureClosure).object!) } /** Schedules the `failure` closure to be invoked on rejected completion of `self`. */ - public func `catch`( - failure: @escaping (Failure) -> (), - file: StaticString = #file, - line: Int = #line - ) { - let closure = JSOneshotClosure { arguments in - guard let error = Failure.construct(from: arguments[0]) else { - fatalError("\(file):\(line): failed to unwrap error value for `catch` callback") - } - failure(error) - return .undefined + @discardableResult + public func `catch`(failure: @escaping (JSValue) -> ConvertibleToJSValue) -> JSPromise { + let closure = JSOneshotClosure { + return failure($0[0]).jsValue() } - _ = jsObject.then!(JSValue.undefined, closure) + return .init(unsafelyWrapping: jsObject.catch!(closure).object!) } - /** Returns a new promise created from chaining the current `self` promise with the `failure` - closure invoked on rejected completion of `self`. The returned promise will have a new type - equal to the return type of `success`. + /** Schedules the `failure` closure to be invoked on either successful or rejected completion of + `self`. */ - public func `catch`( - failure: @escaping (Failure) -> JSPromise, - file: StaticString = #file, - line: Int = #line - ) -> JSPromise { - let closure = JSOneshotClosure { arguments -> JSValue in - guard let error = Failure.construct(from: arguments[0]) else { - fatalError("\(file):\(line): failed to unwrap error value for `catch` callback") - } - return failure(error).jsValue() + @discardableResult + public func finally(successOrFailure: @escaping () -> ()) -> JSPromise { + let closure = JSOneshotClosure { _ in + successOrFailure() + return .undefined } - return .init(unsafe: jsObject.then!(JSValue.undefined, closure).object!) + return .init(unsafelyWrapping: jsObject.finally!(closure).object!) } } diff --git a/Sources/JavaScriptKit/JSBridgedType.swift b/Sources/JavaScriptKit/JSBridgedType.swift index 029a26663..7532f367b 100644 --- a/Sources/JavaScriptKit/JSBridgedType.swift +++ b/Sources/JavaScriptKit/JSBridgedType.swift @@ -35,7 +35,12 @@ public protocol JSBridgedClass: JSBridgedType { extension JSBridgedClass { public var value: JSValue { jsObject.jsValue() } public init?(from value: JSValue) { - guard let object = value.object, object.isInstanceOf(Self.constructor) else { return nil } + guard let object = value.object else { return nil } + self.init(from: object) + } + + public init?(from object: JSObject) { + guard object.isInstanceOf(Self.constructor) else { return nil } self.init(unsafelyWrapping: object) } }