@@ -21,6 +21,7 @@
|
||||
|
||||
(use-fixtures :each
|
||||
tu/fake-server-fixture
|
||||
tu/url-fixture
|
||||
{:before (fn []
|
||||
(reset! @#'webmcp/widget nil)
|
||||
;; The widget logs every tool it registers. (Not console.log as a whole: that is
|
||||
@@ -57,7 +58,7 @@
|
||||
(-> (ping #js {})
|
||||
(.then (fn [result] (is (= "pong" (.. result -content (at 0) -text))))))))
|
||||
(fn []
|
||||
(tu/shows "it found out who is logged in, and shows the todos" "section h2" "Todos"))
|
||||
(tu/eventually (fn [] (some #(= "Todos" (.-textContent %)) (tu/q-all "nav a")))))
|
||||
(fn []
|
||||
(is (= :logged-in (get-in @rf-db/app-db [:user :status])))
|
||||
;; The hot-reload hook redraws and registers the tools again, without starting over.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
(fn [value] (swap! effects conj [id value])))
|
||||
|
||||
(use-fixtures :each
|
||||
tu/url-fixture
|
||||
{:before (fn [] (reset! effects []) (reset! queued []))}
|
||||
(tu/stub-effects-fixture {::events/fetch (record ::events/fetch)
|
||||
::events/copy-to-clipboard (record ::events/copy-to-clipboard)
|
||||
@@ -75,6 +76,28 @@
|
||||
(testing "it asks who is logged in"
|
||||
(is (= [{:uri "/auth/me" :on-response [::events/user-response]}] (requests)))))
|
||||
|
||||
;; --- Pages ---
|
||||
|
||||
(deftest initialize-opens-the-page-in-the-url-test
|
||||
(set! js/location.hash "#/ai-setup")
|
||||
(dispatch! [::events/initialize])
|
||||
(is (= :ai-setup (:page (app-db)))))
|
||||
|
||||
(deftest hash-changed-test
|
||||
(start!)
|
||||
(reset! effects [])
|
||||
(dispatch! [::events/hash-changed "#/todos"])
|
||||
(is (= :todos (:page (app-db))))
|
||||
(dispatch! [::events/hash-changed "#/nowhere"])
|
||||
(is (= :games (:page (app-db))) "an unknown address shows the first page")
|
||||
(is (= [] (requests)) "changing the page asks the server for nothing"))
|
||||
|
||||
(deftest navigate-test
|
||||
(start!)
|
||||
(dispatch! [::events/navigate :ai-setup])
|
||||
(is (= :ai-setup (:page (app-db))) "the page changes at once")
|
||||
(is (= "#/ai-setup" js/location.hash) "and gets its address"))
|
||||
|
||||
;; --- Calculator ---
|
||||
|
||||
(deftest set-operand-test
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
(ns tbd.ui.pages-test
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[tbd.ui.pages :as pages]))
|
||||
|
||||
(deftest page->hash-test
|
||||
(is (= "#/games" (pages/page->hash :games)))
|
||||
(is (= "#/ai-setup" (pages/page->hash :ai-setup))))
|
||||
|
||||
(deftest hash->page-test
|
||||
(testing "every page has an address that opens it"
|
||||
(doseq [{:keys [id]} pages/pages]
|
||||
(is (= id (pages/hash->page (pages/page->hash id))))))
|
||||
(testing "with or without the #"
|
||||
(is (= :todos (pages/hash->page "#/todos")))
|
||||
(is (= :todos (pages/hash->page "/todos")))
|
||||
(is (= :todos (pages/hash->page "todos"))))
|
||||
(testing "what follows the page's name is ignored"
|
||||
(is (= :todos (pages/hash->page "#/todos/1?a=b"))))
|
||||
(testing "no page, or an unknown one, is the first page"
|
||||
(is (= :games pages/default-page))
|
||||
(doseq [hash [nil "" "#" "#/" "#/nowhere" "#/Todos"]]
|
||||
(is (= :games (pages/hash->page hash)) (pr-str hash)))))
|
||||
|
||||
(deftest pages-test
|
||||
(is (= [:games :todos :ai-setup] (mapv :id pages/pages)) "the order of the tabs")
|
||||
(is (= [:todos] (mapv :id (filter :login? pages/pages)))))
|
||||
@@ -23,16 +23,19 @@
|
||||
(deftest plain-subs-test
|
||||
(testing "a new page"
|
||||
(is (= "" (sub [::subs/operand :x])))
|
||||
(is (= :games (sub [::subs/page])))
|
||||
(is (= {:status :idle} (sub [::subs/result])))
|
||||
(is (= {:status :unknown} (sub [::subs/user])))
|
||||
(is (= (:todos db/default-db) (sub [::subs/todos])))
|
||||
(is (false? (sub [::subs/mcp-copied?]))))
|
||||
(testing "they follow the db"
|
||||
(db! assoc :page :todos)
|
||||
(db! assoc-in [:calc :x] "4")
|
||||
(db! assoc-in [:calc :result] {:status :ok :x "4" :y "1" :sum 5})
|
||||
(db! assoc :user {:status :logged-in :login "alice"})
|
||||
(db! assoc-in [:todos :items] [{:id 1 :title "a"}])
|
||||
(db! assoc-in [:mcp :copied?] true)
|
||||
(is (= :todos (sub [::subs/page])))
|
||||
(is (= "4" (sub [::subs/operand :x])))
|
||||
(is (= 5 (:sum (sub [::subs/result]))))
|
||||
(is (= "alice" (:login (sub [::subs/user]))))
|
||||
|
||||
@@ -76,6 +76,12 @@
|
||||
:after (fn []
|
||||
(aset js/globalThis "fetch" real-fetch))})
|
||||
|
||||
(def url-fixture
|
||||
"Each test starts on the page's own address, with no fragment (the page shown is the first)."
|
||||
(let [reset-hash! #(set! js/location.hash "")]
|
||||
{:before reset-hash!
|
||||
:after reset-hash!}))
|
||||
|
||||
(defn log-in! [] (swap! server assoc :user {:login "alice" :id 7}))
|
||||
|
||||
;; --- Stubbing re-frame effects ---
|
||||
|
||||
+116
-28
@@ -7,21 +7,29 @@
|
||||
[re-frame.db :as rf-db]
|
||||
[tbd.ui.db :as db]
|
||||
[tbd.ui.events :as events]
|
||||
[tbd.ui.pages :as pages]
|
||||
[tbd.ui.test-util :as tu :refer [q q-all text shows steps click! type! submit!]]
|
||||
[tbd.ui.views :as views]
|
||||
[tbd.ui.webmcp :as webmcp]))
|
||||
|
||||
(use-fixtures :each
|
||||
tu/fake-server-fixture
|
||||
tu/url-fixture
|
||||
tu/page-fixture
|
||||
;; The Copy button's 2 seconds are up when the test says so (see mcp-copy-button-test).
|
||||
(tu/stub-effects-fixture {:dispatch-later (fn [_])}))
|
||||
|
||||
(defn- open-page!
|
||||
"Starts the app as core/init does, minus the WebMCP widget."
|
||||
[]
|
||||
(rf/dispatch-sync [::events/initialize])
|
||||
(tu/mount! [views/app]))
|
||||
"Starts the app as core/init does, minus the WebMCP widget, on the page whose address is
|
||||
`page` (the first, when there is none)."
|
||||
([] (open-page! nil))
|
||||
([page]
|
||||
(when page (set! js/location.hash (pages/page->hash page)))
|
||||
(rf/dispatch-sync [::events/initialize])
|
||||
(tu/mount! [views/app])))
|
||||
|
||||
(defn- tab-titles []
|
||||
(mapv #(.-textContent %) (q-all "nav a")))
|
||||
|
||||
(defn- todos-section
|
||||
"The todos, which are only on the page when someone is logged in."
|
||||
@@ -39,7 +47,7 @@
|
||||
[todos]
|
||||
(tu/log-in!)
|
||||
(swap! tu/server assoc :todos todos :next-id (inc (count todos)))
|
||||
(steps open-page!
|
||||
(steps #(open-page! :todos)
|
||||
#(tu/eventually todos-section)
|
||||
#(tu/eventually (fn [] (or (q "ul li") (= "Nothing to do." (text "section > p")))))))
|
||||
|
||||
@@ -59,8 +67,8 @@
|
||||
(is (= ["API docs" "swagger.json" "Log in"] (nav-links)))
|
||||
(is (= "/auth/login" (.getAttribute (q "a[href=\"/auth/login\"]") "href")))
|
||||
(is (nil? (todos-section)) "no todos without a login")
|
||||
(is (= ["Tic-tac-toe" "Add this MCP server to Claude Code" "Browser tools (WebMCP)"]
|
||||
(mapv #(.-textContent %) (q-all "h2"))))))
|
||||
(is (= ["Games" "AI setup"] (tab-titles)) "no Todos tab without a login")
|
||||
(is (= ["Tic-tac-toe" "Calculator"] (mapv #(.-textContent %) (q-all "h2"))) "the first page is Games")))
|
||||
done)))
|
||||
|
||||
(deftest login-state-error-test
|
||||
@@ -206,6 +214,7 @@
|
||||
(async done
|
||||
(reset! rf-db/app-db (-> db/default-db
|
||||
(assoc :user {:status :logged-in :login "alice"})
|
||||
(assoc :page :todos)
|
||||
(assoc-in [:todos :status] :loading)))
|
||||
(tu/finish
|
||||
(steps
|
||||
@@ -308,7 +317,9 @@
|
||||
(is (empty? (filter #(= "gold" (.. % -style -background)) (q-all "section button[aria-label^=Cell]"))))))
|
||||
done)))
|
||||
|
||||
;; --- The setup sections ---
|
||||
;; --- The AI setup page ---
|
||||
|
||||
(defn- open-ai-setup! [] (open-page! :ai-setup))
|
||||
|
||||
(defn- with-clipboard
|
||||
"Runs `f` with navigator.clipboard set to `clipboard`, and takes it away afterwards."
|
||||
@@ -321,16 +332,15 @@
|
||||
(async done
|
||||
(tu/finish
|
||||
(steps
|
||||
open-page!
|
||||
#(tu/eventually (fn [] (q "details")))
|
||||
open-ai-setup!
|
||||
#(tu/eventually (fn [] (q "section pre")))
|
||||
(fn []
|
||||
(let [details (first (q-all "details"))]
|
||||
(is (nil? (.getAttribute details "open")) "folded away to start with")
|
||||
(is (= "Add this MCP server to Claude Code" (text "details summary")))
|
||||
(is (= "claude mcp add --transport http tbd http://localhost:3000/mcp" (text "details pre code"))
|
||||
(let [section (first (q-all "section"))]
|
||||
(is (= "Add this MCP server to Claude Code" (text "section h2")))
|
||||
(is (= "claude mcp add --transport http tbd http://localhost:3000/mcp" (text "section pre code"))
|
||||
"the command names the address the page was loaded from")
|
||||
(is (= ["claude" "/mcp" "tbd"]
|
||||
(mapv #(.-textContent %) (.querySelectorAll details "ol code")))))))
|
||||
(mapv #(.-textContent %) (.querySelectorAll section "ol code")))))))
|
||||
done)))
|
||||
|
||||
(deftest mcp-no-copy-button-without-clipboard-test
|
||||
@@ -338,10 +348,10 @@
|
||||
(async done
|
||||
(tu/finish
|
||||
(steps
|
||||
open-page!
|
||||
#(tu/eventually (fn [] (q "details")))
|
||||
open-ai-setup!
|
||||
#(tu/eventually (fn [] (q "section pre")))
|
||||
(fn []
|
||||
(is (nil? (q "details pre + button")))))
|
||||
(is (nil? (q "section pre + button")))))
|
||||
done)))
|
||||
|
||||
(deftest mcp-copy-button-test
|
||||
@@ -352,30 +362,108 @@
|
||||
#js {:writeText (fn [s] (swap! copied conj s) (js/Promise.resolve))}
|
||||
(fn []
|
||||
(steps
|
||||
open-page!
|
||||
#(tu/eventually (fn [] (q "details pre + button")))
|
||||
open-ai-setup!
|
||||
#(tu/eventually (fn [] (q "section pre + button")))
|
||||
(fn []
|
||||
(is (= "Copy" (text "details pre + button")))
|
||||
(click! (q "details pre + button")))
|
||||
#(shows "the button says so" "details pre + button" "Copied")
|
||||
(is (= "Copy" (text "section pre + button")))
|
||||
(click! (q "section pre + button")))
|
||||
#(shows "the button says so" "section pre + button" "Copied")
|
||||
(fn []
|
||||
(is (= ["claude mcp add --transport http tbd http://localhost:3000/mcp"] @copied))
|
||||
;; The 2 seconds are up.
|
||||
(rf/dispatch-sync [::events/mcp-copied-timeout]))
|
||||
#(shows "the button is back" "details pre + button" "Copy"))))
|
||||
#(shows "the button is back" "section pre + button" "Copy"))))
|
||||
done))))
|
||||
|
||||
(deftest browser-tools-test
|
||||
(async done
|
||||
(tu/finish
|
||||
(steps
|
||||
open-page!
|
||||
#(tu/eventually (fn [] (second (q-all "details"))))
|
||||
open-ai-setup!
|
||||
#(tu/eventually (fn [] (second (q-all "section"))))
|
||||
(fn []
|
||||
(let [section (second (q-all "details"))]
|
||||
(is (= "Browser tools (WebMCP)" (.-textContent (.querySelector section "summary"))))
|
||||
(let [section (second (q-all "section"))]
|
||||
(is (= "Browser tools (WebMCP)" (.-textContent (.querySelector section "h2"))))
|
||||
(is (= (str (count webmcp/tools)) (.-textContent (.querySelector section "p strong")))
|
||||
"it counts the tools the page really offers")
|
||||
(is (= "https://webmcp.dev" (.getAttribute (.querySelector section "p a") "href")))
|
||||
(is (= 4 (.-length (.querySelectorAll section "ol li")))))))
|
||||
done)))
|
||||
|
||||
;; --- The tabs ---
|
||||
|
||||
(defn- current-tab []
|
||||
(some-> (q "nav a[aria-current=page]") .-textContent))
|
||||
|
||||
(deftest tabs-test
|
||||
(async done
|
||||
(tu/finish
|
||||
(steps
|
||||
open-page!
|
||||
#(tu/eventually (fn [] (q "nav a")))
|
||||
(fn []
|
||||
(is (= "Games" (current-tab)) "it starts on the first tab")
|
||||
(is (= "#/games" (.getAttribute (q "nav a") "href")))
|
||||
(is (some? (q "[role=status]")) "the game is on the page")
|
||||
(is (nil? (q "details")) "nothing is folded away")
|
||||
(click! (second (q-all "nav a"))))
|
||||
#(tu/eventually (fn [] (= "AI setup" (current-tab))))
|
||||
(fn []
|
||||
(is (= "#/ai-setup" js/location.hash) "a page has an address of its own")
|
||||
(is (nil? (q "[role=status]")) "the game is gone")
|
||||
(is (= ["Add this MCP server to Claude Code" "Browser tools (WebMCP)"]
|
||||
(mapv #(.-textContent %) (q-all "h2"))))
|
||||
;; Back.
|
||||
(set! js/location.hash "#/games"))
|
||||
#(tu/eventually (fn [] (= "Games" (current-tab))))
|
||||
(fn [shown]
|
||||
(is shown "following the address (Back, Forward, a typed one) shows that page")
|
||||
(is (some? (q "[role=status]")))))
|
||||
done)))
|
||||
|
||||
(deftest page-address-test
|
||||
(async done
|
||||
(tu/finish
|
||||
(steps
|
||||
#(open-page! :ai-setup)
|
||||
#(tu/eventually (fn [] (q "nav a")))
|
||||
(fn []
|
||||
(is (= "AI setup" (current-tab)) "a page can be opened by its address")
|
||||
(set! js/location.hash "#/nowhere"))
|
||||
#(tu/eventually (fn [] (= "Games" (current-tab))))
|
||||
(fn [shown]
|
||||
(is shown "an address that names no page shows the first one")))
|
||||
done)))
|
||||
|
||||
(deftest todos-tab-test
|
||||
(async done
|
||||
(tu/finish
|
||||
(steps
|
||||
#(log-in-page! [])
|
||||
(fn []
|
||||
(is (= ["Games" "Todos" "AI setup"] (tab-titles)))
|
||||
(is (= "Todos" (current-tab)))
|
||||
(click! (q "nav a"))
|
||||
(tu/eventually (fn [] (= "Games" (current-tab)))))
|
||||
(fn [_]
|
||||
(is (nil? (todos-section)) "the todos are on their own page")
|
||||
(click! (second (q-all "nav a"))))
|
||||
#(tu/eventually (fn [] (todos-section)))
|
||||
(fn [shown]
|
||||
(is shown "and back")))
|
||||
done)))
|
||||
|
||||
(deftest todos-page-without-login-test
|
||||
;; Its address can be opened (a bookmark) without a login: the page says why it is empty.
|
||||
(async done
|
||||
(tu/finish
|
||||
(steps
|
||||
#(open-page! :todos)
|
||||
#(tu/eventually (fn [] (some #{"Log in"} (nav-links))))
|
||||
(fn []
|
||||
(is (= ["Games" "AI setup"] (tab-titles)))
|
||||
(is (nil? (todos-section)))
|
||||
(is (= "Todos" (text "section h2")))
|
||||
(is (= "Log in" (text "section p a")))
|
||||
(is (= "/auth/login" (.getAttribute (q "section p a") "href")))))
|
||||
done)))
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
:execute execute}))})
|
||||
|
||||
(use-fixtures :each
|
||||
tu/url-fixture
|
||||
{:before (fn []
|
||||
(reset! registered {})
|
||||
((:before tu/fake-server-fixture)))
|
||||
@@ -147,6 +148,59 @@
|
||||
(is (empty? (:todos @tu/server)) "nothing was sent")
|
||||
(done))))))
|
||||
|
||||
;; A tool that acts on the page opens the page it acts on, as a click on its tab would.
|
||||
|
||||
(defn- page [] (:page @rf-db/app-db))
|
||||
|
||||
(deftest tools-open-the-page-they-act-on
|
||||
(tu/log-in!)
|
||||
(swap! tu/server assoc :todos [{:id 1 :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}]
|
||||
:next-id 2)
|
||||
(open-page!)
|
||||
(async done
|
||||
(-> (page-loaded)
|
||||
(.then (fn [_]
|
||||
(rf/dispatch-sync [::events/navigate :ai-setup])
|
||||
(call "get_tic_tac_toe" {})))
|
||||
(.then (fn [_]
|
||||
(is (= :ai-setup (page)) "reading changes nothing")
|
||||
(call "move_tic_tac_toe" {:cell 4})))
|
||||
(.then (fn [_]
|
||||
(is (= :games (page)))
|
||||
(rf/dispatch-sync [::events/navigate :ai-setup])
|
||||
(call "new_tic_tac_toe_game" {})))
|
||||
(.then (fn [_]
|
||||
(is (= :games (page)))
|
||||
(rf/dispatch-sync [::events/navigate :ai-setup])
|
||||
(call "add" {:x 1 :y 2})))
|
||||
(.then (fn [_]
|
||||
(is (= :games (page)) "the calculator is on the games page")
|
||||
(call "list_todos" {})))
|
||||
(.then (fn [_]
|
||||
(is (= :games (page)))
|
||||
(call "create_todo" {:title "buy milk"})))
|
||||
(.then (fn [_]
|
||||
(is (= :todos (page)))
|
||||
(rf/dispatch-sync [::events/navigate :games])
|
||||
(call "set_todo_done" {:id 1 :done true})))
|
||||
(.then (fn [_]
|
||||
(is (= :todos (page)))
|
||||
(rf/dispatch-sync [::events/navigate :games])
|
||||
(call "delete_todo" {:id 1})))
|
||||
(.then (fn [_]
|
||||
(is (= :todos (page)))
|
||||
(done))))))
|
||||
|
||||
(deftest a-tool-that-fails-leaves-the-page-alone
|
||||
(open-page!)
|
||||
(async done
|
||||
(-> (page-loaded)
|
||||
(.then (fn [_] (call "create_todo" {:title "buy milk"})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r) "not logged in")
|
||||
(is (= :games (page)) "so there is no todos page to go to")
|
||||
(done))))))
|
||||
|
||||
(deftest todos
|
||||
(tu/log-in!)
|
||||
(swap! tu/server assoc :todos [{:id 1 :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}]
|
||||
|
||||
Reference in New Issue
Block a user