diff --git a/package-lock.json b/package-lock.json index 1e47bd0..0c53759 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,8 @@ "": { "name": "react-testing-library-cljs", "dependencies": { - "@testing-library/react": "16.3.2" + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "^14.6.1" }, "devDependencies": { "global-jsdom": "^28.0.0", @@ -313,6 +314,19 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", diff --git a/package.json b/package.json index 727a103..c61a60a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "shadow-cljs": "3.3.6" }, "dependencies": { - "@testing-library/react": "16.3.2" + "@testing-library/react": "16.3.2", + "@testing-library/user-event": "14.6.1" } } diff --git a/shadow-cljs.edn b/shadow-cljs.edn index 5c20e00..9670ca2 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -1,4 +1,5 @@ -{:dependencies [[reagent "2.0.1"]] +{:dependencies [[reagent "2.0.1"] + [funcool/promesa "11.0.678"]] :source-paths ["src/main" "src/test"] :builds {:test {:target :node-test diff --git a/src/main/react_testing_library_cljs/async.clj b/src/main/react_testing_library_cljs/async.clj new file mode 100644 index 0000000..1f87f3c --- /dev/null +++ b/src/main/react_testing_library_cljs/async.clj @@ -0,0 +1,30 @@ +(ns react-testing-library-cljs.async) + +(defmacro deftest-async + "Like `cljs.test/deftest` but for async tests that return a Promise. + Wraps the body in `promesa.core/do`, so each top-level form is awaited + before the next runs. Automatically calls `done` when the Promise resolves. + + Requires `promesa` on the classpath. + + (deftest-async my-test + (render-el (react/createElement \"input\" #js {:placeholder \"type here\"})) + (user-event/type (user-event/setup) (screen/get-by-placeholder-text \"type here\") \"hello\") + (is (= \"hello\" (.-value (screen/get-by-placeholder-text \"type here\"))))) + + Use `promesa.core/do` explicitly when async calls share a `let` binding: + + (deftest-async my-test + (let [clicks (atom 0)] + (render-el (react/createElement \"button\" #js {:onClick #(swap! clicks inc)} \"Click\")) + (p/do + (user-event/click (user-event/setup) (screen/get-by-role \"button\")) + (is (= 1 @clicks)))))" + [name & body] + `(cljs.test/deftest ~name + (cljs.test/async done# + (-> (promesa.core/do ~@body) + (.then done#) + (.catch (fn [err#] + (cljs.test/is (nil? err#) (.-message err#)) + (done#))))))) diff --git a/src/main/react_testing_library_cljs/async.cljs b/src/main/react_testing_library_cljs/async.cljs new file mode 100644 index 0000000..24a19fc --- /dev/null +++ b/src/main/react_testing_library_cljs/async.cljs @@ -0,0 +1,2 @@ +(ns react-testing-library-cljs.async + (:require-macros [react-testing-library-cljs.async])) diff --git a/src/main/react_testing_library_cljs/user_event.cljs b/src/main/react_testing_library_cljs/user_event.cljs new file mode 100644 index 0000000..9a809f8 --- /dev/null +++ b/src/main/react_testing_library_cljs/user_event.cljs @@ -0,0 +1,136 @@ +(ns react-testing-library-cljs.user-event + "ClojureScript wrapper for @testing-library/user-event. + + Simulates real user interactions by firing the full sequence of events + (focus, keyboard, pointer, input, etc.) rather than a single synthetic event. + + Refer to https://testing-library.com/docs/user-event/intro for more details." + (:require + ["@testing-library/user-event" :default userEvent])) + +(defn setup + "Creates a UserEvent instance for simulating user interactions. + Should be called once per test. All interactions on the instance share + browser state (clipboard, keyboard, pointer, etc.). + + All methods on the returned instance return a Promise. + + See [userEvent.setup](https://testing-library.com/docs/user-event/setup)." + ([] (.setup userEvent)) + ([options] (.setup userEvent (clj->js options)))) + +(defn click + "Clicks an element. + + See [click](https://testing-library.com/docs/user-event/convenience/#click)." + ([user element] (.click user element)) + ([user element options] (.click user element (clj->js options)))) + +(defn dbl-click + "Double-clicks an element. + + See [dblClick](https://testing-library.com/docs/user-event/convenience/#dblclick)." + ([user element] (.dblClick user element)) + ([user element options] (.dblClick user element (clj->js options)))) + +(defn triple-click + "Triple-clicks an element (selects all text in an input). + + See [tripleClick](https://testing-library.com/docs/user-event/convenience/#tripleclick)." + ([user element] (.tripleClick user element)) + ([user element options] (.tripleClick user element (clj->js options)))) + +(defn hover + "Moves the pointer to an element. + + See [hover](https://testing-library.com/docs/user-event/convenience/#hover)." + ([user element] (.hover user element)) + ([user element options] (.hover user element (clj->js options)))) + +(defn unhover + "Moves the pointer away from an element. + + See [unhover](https://testing-library.com/docs/user-event/convenience/#unhover)." + ([user element] (.unhover user element)) + ([user element options] (.unhover user element (clj->js options)))) + +(defn type + "Types text into an element character by character, simulating the full + keyboard event sequence for each character. + + See [type](https://testing-library.com/docs/user-event/utility/#type)." + ([user element text] (.type user element text)) + ([user element text options] (.type user element text (clj->js options)))) + +(defn clear + "Selects all text in an element and deletes it. + + See [clear](https://testing-library.com/docs/user-event/utility/#clear)." + [user element] (.clear user element)) + +(defn select-options + "Selects one or more options in a `` element. + `values` can be a single value or a vector of values (option text, value, or element). + + See [deselectOptions](https://testing-library.com/docs/user-event/utility/#deselectoptions)." + ([user element values] (.deselectOptions user element (clj->js values))) + ([user element values options] (.deselectOptions user element (clj->js values) (clj->js options)))) + +(defn upload + "Simulates uploading a file via a file input. + `files` can be a single File or a vector of Files. + + See [upload](https://testing-library.com/docs/user-event/utility/#upload)." + ([user element files] (.upload user element files)) + ([user element files options] (.upload user element files (clj->js options)))) + +(defn tab + "Presses the Tab key, moving focus to the next focusable element. + + See [tab](https://testing-library.com/docs/user-event/convenience/#tab)." + ([user] (.tab user)) + ([user options] (.tab user (clj->js options)))) + +(defn keyboard + "Simulates keyboard events for the given key descriptor string + (e.g. `\"{Enter}\"`, `\"a\"`, `\"{{a}}\"`). + + See [keyboard](https://testing-library.com/docs/user-event/keyboard)." + ([user text] (.keyboard user text)) + ([user text options] (.keyboard user text (clj->js options)))) + +(defn pointer + "Simulates pointer device interactions. + + See [pointer](https://testing-library.com/docs/user-event/pointer)." + ([user input] (.pointer user (clj->js input))) + ([user input options] (.pointer user (clj->js input) (clj->js options)))) + +(defn copy + "Copies the current selection to the clipboard. + + See [copy](https://testing-library.com/docs/user-event/clipboard/#copy)." + ([user] (.copy user)) + ([user options] (.copy user (clj->js options)))) + +(defn cut + "Cuts the current selection to the clipboard. + + See [cut](https://testing-library.com/docs/user-event/clipboard/#cut)." + ([user] (.cut user)) + ([user options] (.cut user (clj->js options)))) + +(defn paste + "Pastes clipboard contents into the focused element. + + See [paste](https://testing-library.com/docs/user-event/clipboard/#paste)." + ([user] (.paste user)) + ([user options] (.paste user (clj->js options)))) diff --git a/src/test/react_testing_library_cljs/user_event_promesa_test.cljs b/src/test/react_testing_library_cljs/user_event_promesa_test.cljs new file mode 100644 index 0000000..da52db5 --- /dev/null +++ b/src/test/react_testing_library_cljs/user_event_promesa_test.cljs @@ -0,0 +1,69 @@ +(ns react-testing-library-cljs.user-event-promesa-test + (:require + [cljs.test :refer [is]] + ["@testing-library/react" :as rtl] + ["react" :as react] + [promesa.core :as p] + [react-testing-library-cljs.async :refer-macros [deftest-async]] + [react-testing-library-cljs.screen :as screen] + [react-testing-library-cljs.user-event :as user-event])) + +(defn- render-el [element] + (rtl/render element)) + +(deftest-async click-test + (rtl/cleanup) + (let [clicks (atom 0)] + (render-el (react/createElement "button" + #js {:onClick #(swap! clicks inc)} + "Click me")) + (p/do + (user-event/click (user-event/setup) (screen/get-by-role "button")) + (is (= 1 @clicks))))) + +(deftest-async type-test + (rtl/cleanup) + (render-el (react/createElement "input" + #js {:placeholder "type here" + :defaultValue ""})) + (user-event/type (user-event/setup) (screen/get-by-placeholder-text "type here") "hello") + (is (= "hello" (.-value (screen/get-by-placeholder-text "type here"))))) + +(deftest-async clear-test + (rtl/cleanup) + (render-el (react/createElement "input" + #js {:placeholder "to clear" + :defaultValue "existing text"})) + (user-event/clear (user-event/setup) (screen/get-by-placeholder-text "to clear")) + (is (= "" (.-value (screen/get-by-placeholder-text "to clear"))))) + +(deftest-async tab-test + (rtl/cleanup) + (render-el (react/createElement "div" nil + (react/createElement "input" #js {:placeholder "first"}) + (react/createElement "input" #js {:placeholder "second"}))) + (.focus (screen/get-by-placeholder-text "first")) + (user-event/tab (user-event/setup)) + (is (= (screen/get-by-placeholder-text "second") (.-activeElement js/document)))) + +(deftest-async keyboard-test + (rtl/cleanup) + (let [submitted (atom false)] + (render-el (react/createElement "form" + #js {:onSubmit (fn [e] + (.preventDefault e) + (reset! submitted true))} + (react/createElement "input" #js {:placeholder "press enter"}))) + (.focus (screen/get-by-placeholder-text "press enter")) + (p/do + (user-event/keyboard (user-event/setup) "{Enter}") + (is (true? @submitted))))) + +(deftest-async select-options-test + (rtl/cleanup) + (render-el (react/createElement "select" + #js {:defaultValue ""} + (react/createElement "option" #js {:value "a"} "Option A") + (react/createElement "option" #js {:value "b"} "Option B"))) + (user-event/select-options (user-event/setup) (screen/get-by-role "combobox") "Option A") + (is (= "a" (.-value (screen/get-by-role "combobox"))))) diff --git a/src/test/react_testing_library_cljs/user_event_test.cljs b/src/test/react_testing_library_cljs/user_event_test.cljs new file mode 100644 index 0000000..60e3d9f --- /dev/null +++ b/src/test/react_testing_library_cljs/user_event_test.cljs @@ -0,0 +1,94 @@ +(ns react-testing-library-cljs.user-event-test + (:require + [cljs.test :refer [deftest is async]] + ["@testing-library/react" :as rtl] + ["react" :as react] + [react-testing-library-cljs.screen :as screen] + [react-testing-library-cljs.user-event :as user-event])) + +(defn- render-el [element] + (rtl/render element)) + +(deftest click-test + (async done + (rtl/cleanup) + (let [clicks (atom 0)] + (render-el (react/createElement "button" + #js {:onClick #(swap! clicks inc)} + "Click me")) + (let [user (user-event/setup)] + (-> (user-event/click user (screen/get-by-role "button")) + (.then (fn [] + (is (= 1 @clicks)) + (done)))))))) + +(deftest type-test + (async done + (rtl/cleanup) + (render-el (react/createElement "input" + #js {:placeholder "type here" + :defaultValue ""})) + (let [user (user-event/setup) + input (screen/get-by-placeholder-text "type here")] + (-> (user-event/type user input "hello") + (.then (fn [] + (is (= "hello" (.-value input))) + (done))))))) + +(deftest clear-test + (async done + (rtl/cleanup) + (render-el (react/createElement "input" + #js {:placeholder "to clear" + :defaultValue "existing text"})) + (let [user (user-event/setup) + input (screen/get-by-placeholder-text "to clear")] + (-> (user-event/clear user input) + (.then (fn [] + (is (= "" (.-value input))) + (done))))))) + +(deftest tab-test + (async done + (rtl/cleanup) + (render-el (react/createElement "div" nil + (react/createElement "input" #js {:placeholder "first"}) + (react/createElement "input" #js {:placeholder "second"}))) + (let [user (user-event/setup) + first-input (screen/get-by-placeholder-text "first") + second-input (screen/get-by-placeholder-text "second")] + (.focus first-input) + (-> (user-event/tab user) + (.then (fn [] + (is (= second-input (.-activeElement js/document))) + (done))))))) + +(deftest keyboard-test + (async done + (rtl/cleanup) + (let [submitted (atom false)] + (render-el (react/createElement "form" + #js {:onSubmit (fn [e] + (.preventDefault e) + (reset! submitted true))} + (react/createElement "input" #js {:placeholder "press enter"}))) + (let [user (user-event/setup)] + (.focus (screen/get-by-placeholder-text "press enter")) + (-> (user-event/keyboard user "{Enter}") + (.then (fn [] + (is (true? @submitted)) + (done)))))))) + +(deftest select-options-test + (async done + (rtl/cleanup) + (render-el (react/createElement "select" + #js {:defaultValue ""} + (react/createElement "option" #js {:value "a"} "Option A") + (react/createElement "option" #js {:value "b"} "Option B"))) + (let [user (user-event/setup) + select (screen/get-by-role "combobox")] + (-> (user-event/select-options user select "Option A") + (.then (fn [] + (is (= "a" (.-value select))) + (done)))))))