set up pages in UI
CI / Test + coverage (push) Successful in 1m9s

This commit is contained in:
2026-09-21 06:20:30 -09:00
parent 47f6cbf2d1
commit 10f4ca654b
14 changed files with 382 additions and 69 deletions
+2 -1
View File
@@ -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.
+23
View File
@@ -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
+26
View File
@@ -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)))))
+3
View File
@@ -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]))))
+6
View File
@@ -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
View File
@@ -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)))
+54
View File
@@ -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}]