diff --git a/changelog.md b/changelog.md index 89e9763..76b8d0e 100644 --- a/changelog.md +++ b/changelog.md @@ -13,7 +13,8 @@ - [Always reinstantiate mocks for each test case when using `setup-mocks`.](https://github.com/pitch-io/cljest/pull/28) - [Fix matcher negation.](https://github.com/pitch-io/cljest/pull/27) - [Correctly determine the files to run based off of the namespace regex.](https://github.com/pitch-io/cljest/pull/33) - +- [Properly handle an `await`-ed `let` binding value](https://github.com/pitch-io/cljest/pull/35). Fixes [#31](https://github.com/pitch-io/cljest/issues/31) +- [Correctly handle multiple binding pairs inside of `let`](https://github.com/pitch-io/cljest/pull/35). Fixes [#30](https://github.com/pitch-io/cljest/issues/30). # 1.0.0 diff --git a/cljest/src/cljest/helpers/core.clj b/cljest/src/cljest/helpers/core.clj index 19bdda7..8f061e3 100644 --- a/cljest/src/cljest/helpers/core.clj +++ b/cljest/src/cljest/helpers/core.clj @@ -51,16 +51,85 @@ (.finally finish#)))) (defn ^:private group - "Creates a new \"group\" map that has `forms` and `bindings` keys." + "Creates a new 'group' map that has `forms` and `bindings` keys." ([] (group [] [])) ([forms] (group forms [])) ([forms bindings] {:forms forms :bindings bindings})) -(defn ^:private await-seq? - "If the given `form`, as a list of quoted symbols, is eqv to `(list 'await ...)`" +(defn ^:private await-form? + "Returns true if the given `form` is a sequence and the first element is `'await`." [form] (and (seq? form) (= 'await (first form)))) +(defn ^:private let-form? + "Returns true if the given `form` is a sequence and the first element is `'let`." + [form] + (and (seq? form) (= 'let (first form)))) + +(defn ^:private unwrap-await + "If `form` is an `await`-wrapped form, return what it is wrapping." + [form] + (if (await-form? form) + (second form) + form)) + +(defn ^:private forms->groups + "Takes all `forms` inside of an `async` block and turns them into `groups`, which later get turned into `.then` + calls." + [forms] + (let [{:keys [current prev]} + (reduce + (fn [{prev :prev {:keys [forms bindings]} :current} form] + (cond + ;; If the form itself is `(await ...)`, take the inner part and add it to the current group, + ;; then add the current group to the `prev` sequence. + (await-form? form) + {:current (group) + :prev (conj prev + (group (conj forms (second form)) bindings))} + + ;; If the form is `let` and any of the binding values has `(await ...)`, take the first binding pair + ;; and use its value as the return value of the current group. Add a new group with the symbol as the + ;; first binding. + (and (let-form? form) (some await-form? (second form))) + (let [all-bindings (second form) + first-binding-sym (first all-bindings) + first-binding-val (unwrap-await (second all-bindings)) + rest-bindings (nthrest all-bindings 2) + let-forms (nthrest form 2) + + ;; If there aren't any more bindings (which would generate `(let [] forms)`), + ;; use `forms` instead of creating another `let`. + next-async-expr (if (empty? rest-bindings) + (concat ['cljest.helpers.core/async] let-forms) + (list 'cljest.helpers.core/async (concat (list 'let rest-bindings) let-forms)))] + {:current (group) + :prev (conj prev + (group (conj forms first-binding-val)) + (group [next-async-expr] [first-binding-sym]))}) + + ;; If the form is `let` but there aren't any `await` calls in the binding values, just create a new + ;; `async` wrapped group. + (let-form? form) + {:current (group) + :prev (conj prev + (group + (conj forms (list 'let (second form) (concat ['cljest.helpers.core/async] (nthrest form 2)))) + bindings))} + + ;; Otherwise, add the current form to the current group's forms. + :else + {:current (group (conj forms form) bindings) + :prev prev})) + {:current (group) + :prev []} + forms)] + + ;; Prevent unnecessary functions from being added to the result + (if (empty? (:forms current)) + prev + (conj prev current)))) + (defmacro async "Similar to JS's async/await. Wraps the body of `async` in a promise and allows for the use of `await`, which when called will wait for the promise to finish before continuing execution @@ -94,55 +163,20 @@ (some-fn))) ``` " - [& body] - (let [then-groups (->> body - (reduce - (fn [{rest :rest {:keys [forms bindings]} :current} form] - (cond - ;; If the form itself is `(await ...)`, take the `...`, add it to the current group, - ;; and add the current group to the `rest`. - (await-seq? form) - {:current (group) - :rest (conj rest (group (conj forms (second form)) bindings))} - - ;; If the form is `let` and any of the binding values has `(await ...)`, add a new - ;; `js/Promise.all` to the rest and add the body of the `let` as a second new group, - ;; wrapped in `async`, with the binding names from the `let` as the arguments of the - ;; `.then` function. - (and (= 'let (first form)) (some await-seq? (second form))) - (let [bindings (second form) - let-exprs (nthrest form 2) - binding-names (take-nth 2 bindings) - binding-vals (map - #(if (await-seq? %) (second %) %) - (take-nth 2 (drop 1 bindings)))] - {:current (group) - :rest (conj rest - (group (conj forms (list 'js/Promise.all binding-vals))) - (group [(concat ['cljest.helpers.core/async] let-exprs)] binding-names))}) - - ;; If we have `let` but there aren't any `await` calls in the binding values, just create a new - ;; `async` wrapped group. - (= 'let (first form)) - {:current (group) - :rest (conj rest - (group - (conj forms (list 'let (second form) (concat ['cljest.helpers.core/async] (nthrest form 2)))) - bindings))} - - ;; Otherwise, add the current form to the current group's forms. - :else - {:current (group (conj forms form) bindings) - :rest rest})) - {:current (group) - :rest []}) - - ;; Prevent unnecessary functions from being added to the result - ((fn [{:keys [current rest]}] - (if (empty? (:forms current)) - rest - (conj rest current)))) + [& forms] + (let [groups (forms->groups forms) + ;; We can assume there are no bindings for the first group, since bindings necessarily must come + ;; from a previous group. + first-forms (:forms (first groups)) + then-groups (->> groups + rest (map (fn [{:keys [forms bindings]}] (list '.then (concat (list 'fn (apply vector bindings)) forms)))))] - `(-> (js/Promise.resolve) - ~@then-groups))) + + `(do ~@(butlast first-forms) + (let [beginning# ~(last first-forms)] + ;; This avoids creating a new Promise instance if `beginning#` is thennable + (-> (if (and beginning# (.-then beginning#)) + beginning# + (js/Promise.resolve beginning#)) + ~@then-groups))))) diff --git a/cljest/src/cljest/helpers/core_test.cljs b/cljest/src/cljest/helpers/core_test.cljs index 1f7d97f..e7f7890 100644 --- a/cljest/src/cljest/helpers/core_test.cljs +++ b/cljest/src/cljest/helpers/core_test.cljs @@ -1,7 +1,11 @@ (ns cljest.helpers.core-test - (:require [cljest.core :refer [describe is it]] + (:require [cljest.core :refer [describe is it spy]] [cljest.helpers.core :as h] - [cyrik.cljs-macroexpand :refer [cljs-macroexpand-all] :rename {cljs-macroexpand-all macroexpand-all}])) + [cljest.matchers :as m])) + +(defn ^:private next-macrotask+ + [] + (js/Promise. (fn [res _] (js/setTimeout res)))) (describe "with-mocks" (defn ^:private cool-fn @@ -49,96 +53,37 @@ (is (= -2 (something-else-stateful))))) (describe "async" - (it "should macroexpand into a resolves promise when called with nothing" - (is (= (macroexpand-all '(js/Promise.resolve)) - (macroexpand-all '(h/async))))) - - (it "should add the provided form to the body of the `then` function" - (is (= (macroexpand-all '(-> (js/Promise.resolve) - (.then (fn [] - (fn-1) - (fn-2 with-an-arg))))) - (macroexpand-all '(h/async - (fn-1) - (fn-2 with-an-arg)))))) - - (it "should handle the `await` keyword by separating the bodies with then" - (is (= (macroexpand-all '(-> (js/Promise.resolve) - (.then (fn [] - (fn-1) - (async-fn-2))) - (.then (fn [] - (fn-3 with-an-arg))))) - - (macroexpand-all '(h/async - (fn-1) - (await (async-fn-2)) - (fn-3 with-an-arg)))))) - - (it "should handle `await` inside of `let` and turn the let body into another `async` call" - (is (= (macroexpand-all '(-> (js/Promise.resolve) - (.then (fn [] - (async-fn-1))) - (.then (fn [] - (let [name-1 :kw] - (-> (js/Promise.resolve) - (.then (fn [] - (fn-2) - (async-fn-3 name-1))))))) - (.then (fn [] - (fn-4))))) - - (macroexpand-all '(h/async (await (async-fn-1)) - (let [name-1 :kw] - (fn-2) - (await (async-fn-3 name-1))) - (fn-4)))))) - - (it "should turn await inside of a let binding value into Promise.all" - (is (= (macroexpand-all '(-> (js/Promise.resolve) - (.then (fn [] - (fn-1) - (js/Promise.all [promise-1 non-promise promise-2]))) - (.then (fn [name-1 name-2 name-3] - (-> (js/Promise.resolve) - (.then (fn [] - (fn-2) - (async-fn-3 name-3)))))) - (.then (fn [] - (fn-4))))) - - (macroexpand-all '(h/async (fn-1) - (let [name-1 (await promise-1) - name-2 non-promise - name-3 (await promise-2)] - (fn-2) - (await (async-fn-3 name-3))) - (fn-4)))))) - (it "should handle nested lets" - (is (= (macroexpand-all '(-> (js/Promise.resolve) - (.then (fn [] - (async-fn-1))) - (.then (fn [] - (fn-2) - (let [name-1 :kw] - (-> (js/Promise.resolve) - (.then (fn [] - (fn-3 name-1) - (js/Promise.all [:kw-1 promise-1]))) - (.then (fn [name-1-1 name-2-2] - (-> (js/Promise.resolve) - (.then (fn [] - (fn-4 name-2-2)))))))))) - - (.then (fn [] - (fn-5))))) - - (macroexpand-all '(h/async (await (async-fn-1)) - (fn-2) - (let [name-1 :kw] - (fn-3 name-1) - (let [name-1-1 :kw-1 - name-2-2 (await promise-1)] - (fn-4 name-2-2))) - (fn-5))))))) + (it "should support basic `await` usage" + (let [cb (spy) + timer (js/setInterval cb)] + (h/async + (is (m/called-times? cb 0)) + + (await (next-macrotask+)) + + (is (m/called-times? cb 1)) + + (await (next-macrotask+)) + + (is (m/called-times? cb 2)) + + (js/clearInterval timer)))) + + (it "should return a promise even with a non-promise value" + (js/expect.assertions 1) + + (.. (h/async 7) + (then (fn [resolved] + (is (= 7 resolved)))))) + + (it "should support arbitrary `await`-ed bindings inside of `let`" + (h/async + (let [value-1 {:a-key {:b-key "yeah dude"}} + value-2 (await (js/Promise.resolve value-1)) + {value-3 :a-key} value-2 + {value-4 :b-key} (await (js/Promise.resolve value-3))] + (is (= {:a-key {:b-key "yeah dude"}} value-1)) + (is (= {:a-key {:b-key "yeah dude"}} value-2)) + (is (= {:b-key "yeah dude"} value-3)) + (is (= "yeah dude" value-4))))))