Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify JSPromise API #115

Merged
merged 4 commits into from
Jan 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,32 @@ func expectNotNil<T>(_ 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
}
}
}
68 changes: 64 additions & 4 deletions IntegrationTests/TestSuites/Sources/PrimaryTests/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -620,3 +678,5 @@ try test("Exception") {
let errorObject3 = JSError(from: ageError as! JSValue)
try expectNotNil(errorObject3)
}

Expectation.wait(expectations)
208 changes: 46 additions & 162 deletions Sources/JavaScriptKit/BasicObjects/JSPromise.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Success, Failure>: ConvertibleToJSValue, ConstructibleFromJSValue {
public final class JSPromise: JSBridgedClass {
/// The underlying JavaScript `Promise` object.
public let jsObject: JSObject

Expand All @@ -18,17 +18,20 @@ public final class JSPromise<Success, Failure>: 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`
Expand All @@ -40,73 +43,10 @@ public final class JSPromise<Success, Failure>: 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<Success, JSError>) -> ()) -> ()) {
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<Success, JSError>) -> ()) -> ()) {
public convenience init(resolver: @escaping (@escaping (Result<JSValue, JSValue>) -> ()) -> ()) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could these be JSValueConvertible so users can do something like resolve(.success("Hello, world!"))?

Suggested change
public convenience init(resolver: @escaping (@escaping (Result<JSValue, JSValue>) -> ()) -> ()) {
public convenience init(resolver: @escaping (@escaping (Result< JSValueConvertible, JSValueConvertible >) -> ()) -> ()) {

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConvertibleToJSValue is not conforming to Error, so Result can't have Failure as ConvertibleToJSValue 😢

let closure = JSOneshotClosure { arguments in
// The arguments are always coming from the `Promise` constructor, so we should be
// safe to assume their type here
Expand All @@ -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<ResultType: ConvertibleToJSValue>(
success: @escaping (Success) -> ResultType,
file: StaticString = #file,
line: Int = #line
) -> JSPromise<ResultType, Failure> {
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<ResultSuccess: ConvertibleToJSValue, ResultFailure: ConstructibleFromJSValue>(
success: @escaping (Success) -> JSPromise<ResultSuccess, ResultFailure>,
file: StaticString = #file,
line: Int = #line
) -> JSPromise<ResultSuccess, ResultFailure> {
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`<ResultSuccess: ConvertibleToJSValue>(
failure: @escaping (Failure) -> ResultSuccess,
file: StaticString = #file,
line: Int = #line
) -> JSPromise<ResultSuccess, Never> {
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`<ResultSuccess: ConvertibleToJSValue, ResultFailure: ConstructibleFromJSValue>(
failure: @escaping (Failure) -> JSPromise<ResultSuccess, ResultFailure>,
file: StaticString = #file,
line: Int = #line
) -> JSPromise<ResultSuccess, ResultFailure> {
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!)
}
}
Loading