@@ -1,5 +1,6 @@
|
||||
(ns tbd.ui.events-test
|
||||
(:require [cljs.test :refer [deftest is testing async use-fixtures]]
|
||||
[clojure.string :as str]
|
||||
[re-frame.core :as rf]
|
||||
[re-frame.db :as rf-db]
|
||||
[tbd.ui.db :as db]
|
||||
@@ -62,8 +63,8 @@
|
||||
(reset! effects [])
|
||||
(dispatch! [::events/initialize]))
|
||||
|
||||
(def ^:private todo-1 {:id 1 :owner-id 7 :title "buy milk" :done false :created-at 1 :updated-at 1})
|
||||
(def ^:private todo-2 {:id 2 :owner-id 7 :title "walk dog" :done false :created-at 2 :updated-at 2})
|
||||
(def ^:private todo-1 {:id (tu/todo-uuid 1) :owner-id 7 :title "buy milk" :done false :created-at 1 :updated-at 1})
|
||||
(def ^:private todo-2 {:id (tu/todo-uuid 2) :owner-id 7 :title "walk dog" :done false :created-at 2 :updated-at 2})
|
||||
|
||||
(defn- logged-in!
|
||||
"The page is up, alice is logged in and her todos (`items`) are shown."
|
||||
@@ -94,7 +95,7 @@
|
||||
(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 (= :tic-tac-toe (:page (app-db))) "an unknown address shows the first page")
|
||||
(is (= [] (requests)) "changing the page asks the server for nothing"))
|
||||
|
||||
(deftest navigate-test
|
||||
@@ -237,27 +238,27 @@
|
||||
|
||||
(deftest set-done-test
|
||||
(logged-in! [todo-1 todo-2])
|
||||
(dispatch! [::events/set-done 2 true])
|
||||
(is (= {:uri "/api/todos/2" :method "PATCH" :body {:done true} :on-response [::events/todo-changed]}
|
||||
(dispatch! [::events/set-done (:id todo-2) true])
|
||||
(is (= {:uri (str "/api/todos/" (:id todo-2)) :method "PATCH" :body {:done true} :on-response [::events/todo-changed]}
|
||||
(last-request)))
|
||||
(testing "the todo is replaced by what the server made of it, in place"
|
||||
(answer! 200 (assoc todo-2 :done true :updated-at 5))
|
||||
(is (= [todo-1 (assoc todo-2 :done true :updated-at 5)] (get-in (app-db) [:todos :items]))))
|
||||
(testing "a failure keeps the list and shows why"
|
||||
(dispatch! [::events/set-done 1 true])
|
||||
(dispatch! [::events/set-done (:id todo-1) true])
|
||||
(answer! 404 {:error "todo not found"})
|
||||
(is (= "todo not found" (get-in (app-db) [:todos :error])))
|
||||
(is (false? (:done (first (get-in (app-db) [:todos :items])))))))
|
||||
|
||||
(deftest delete-todo-test
|
||||
(logged-in! [todo-1 todo-2])
|
||||
(dispatch! [::events/delete-todo 1])
|
||||
(is (= {:uri "/api/todos/1" :method "DELETE" :on-response [::events/todo-deleted 1]} (last-request)))
|
||||
(dispatch! [::events/delete-todo (:id todo-1)])
|
||||
(is (= {:uri (str "/api/todos/" (:id todo-1)) :method "DELETE" :on-response [::events/todo-deleted (:id todo-1)]} (last-request)))
|
||||
(testing "it goes from the list"
|
||||
(answer! 200 {:deleted true})
|
||||
(is (= [todo-2] (get-in (app-db) [:todos :items]))))
|
||||
(testing "a failure keeps the list and shows why"
|
||||
(dispatch! [::events/delete-todo 2])
|
||||
(dispatch! [::events/delete-todo (:id todo-2)])
|
||||
(answer! 500 {:error "Boom"})
|
||||
(is (= [todo-2] (get-in (app-db) [:todos :items])))
|
||||
(is (= "Boom" (get-in (app-db) [:todos :error])))))
|
||||
@@ -265,8 +266,8 @@
|
||||
(deftest expired-session-test
|
||||
(testing "a 401 to any change falls back to the logged-out view"
|
||||
(doseq [[event] [[[::events/add-todo]]
|
||||
[[::events/set-done 1 true]]
|
||||
[[::events/delete-todo 1]]]]
|
||||
[[::events/set-done (:id todo-1) true]]
|
||||
[[::events/delete-todo (:id todo-1)]]]]
|
||||
(logged-in! [todo-1])
|
||||
(dispatch! event)
|
||||
(answer! 401 {:error "not logged in"})
|
||||
@@ -308,7 +309,7 @@
|
||||
(testing "the trimmed message joins the conversation and goes to the server, for a stream back"
|
||||
(is (= [::events/fetch-stream
|
||||
{:uri "/api/chat" :method "POST"
|
||||
:body {:messages [{:role "user" :content "hello there"}] :page "games"}
|
||||
:body {:messages [{:role "user" :content "hello there"}] :page "tic-tac-toe"}
|
||||
:on-event [::events/chat-event]
|
||||
:on-response [::events/chat-response]}]
|
||||
(last @effects)))
|
||||
@@ -364,6 +365,24 @@
|
||||
{:role "user" :content "again"}]
|
||||
(get-in (last-request) [:body :messages])))))
|
||||
|
||||
(deftest chat-thinking-time-test
|
||||
;; The view animates "Thinking" while pieces of the reasoning are still coming in: the time of
|
||||
;; the last one is what it goes by.
|
||||
(logged-in! [])
|
||||
(is (nil? (:thinking-at (chat))))
|
||||
(say! "hello")
|
||||
(is (nil? (:thinking-at (chat))) "nothing has been thought yet")
|
||||
(event! {:thinking "Let me "})
|
||||
(let [at (:thinking-at (chat))]
|
||||
(is (number? at))
|
||||
(event! {:content "Hi"})
|
||||
(is (= at (:thinking-at (chat))) "only reasoning moves it"))
|
||||
(event! {:thinking ""})
|
||||
(event! {:done true})
|
||||
(is (nil? (:thinking-at (chat))) "the reply is over")
|
||||
(event! {:thinking "late"})
|
||||
(is (nil? (:thinking-at (chat))) "reasoning nobody waits for is dropped"))
|
||||
|
||||
(deftest chat-thinking-and-text-in-one-event-test
|
||||
(logged-in! [])
|
||||
(say! "hello")
|
||||
@@ -469,6 +488,19 @@
|
||||
(dispatch! [::events/chat-clear])
|
||||
(is (= (assoc (:chat db/default-db) :open? true) (chat)) "starts over, and the panel stays as it was"))
|
||||
|
||||
(deftest chat-toggle-tools-test
|
||||
(logged-in! [])
|
||||
(is (false? (:tools-expanded? (chat))) "tool use is collapsed to a line each by default")
|
||||
(dispatch! [::events/chat-toggle-tools])
|
||||
(is (true? (:tools-expanded? (chat))))
|
||||
(testing "the choice stays when the conversation starts over"
|
||||
(say! "hello")
|
||||
(reply! "Hi!")
|
||||
(dispatch! [::events/chat-clear])
|
||||
(is (true? (:tools-expanded? (chat)))))
|
||||
(dispatch! [::events/chat-toggle-tools])
|
||||
(is (false? (:tools-expanded? (chat)))))
|
||||
|
||||
(deftest chat-resize-test
|
||||
(logged-in! [])
|
||||
(is (= {:width 320 :height 256} (:size (chat))) "the size it starts with")
|
||||
@@ -544,7 +576,7 @@
|
||||
(logged-in! [])
|
||||
(testing "the page the user is on goes along with the conversation"
|
||||
(say! "hi")
|
||||
(is (= "games" (:page (:body (last-request))))))
|
||||
(is (= "tic-tac-toe" (:page (:body (last-request))))))
|
||||
(testing "and follows them, or the LLM itself, to the next page"
|
||||
(reply! "hello")
|
||||
(dispatch! [::events/navigate :ai-setup])
|
||||
@@ -564,13 +596,13 @@
|
||||
(deftest chat-offers-the-tools-of-the-page-test
|
||||
(logged-in! [])
|
||||
(model-context/register! [{:name "ping" :description "Pings" :input-schema add-schema :handler identity}
|
||||
{:name "add" :pages #{:games} :description "Adds" :input-schema add-schema :handler identity}
|
||||
{:name "add" :pages #{:tic-tac-toe} :description "Adds" :input-schema add-schema :handler identity}
|
||||
{:name "list_todos" :pages #{:todos} :description "Lists" :input-schema add-schema
|
||||
:handler identity}])
|
||||
(let [offered (fn [] (map :name (:tools (:body (last-request)))))]
|
||||
(testing "on a page, its tools and those of every page are offered"
|
||||
(say! "hi")
|
||||
(is (= :games (:page (app-db))))
|
||||
(is (= :tic-tac-toe (:page (app-db))))
|
||||
(is (= ["ping" "add"] (offered))))
|
||||
(testing "the tools follow the page the user is on when the message is sent"
|
||||
(reply! "hello")
|
||||
@@ -585,14 +617,14 @@
|
||||
|
||||
(deftest chat-tool-calls-go-by-the-page-test
|
||||
(logged-in! [])
|
||||
(model-context/register! [{:name "add" :pages #{:games} :description "Adds" :input-schema add-schema :handler identity}])
|
||||
(model-context/register! [{:name "add" :pages #{:tic-tac-toe} :description "Adds" :input-schema add-schema :handler identity}])
|
||||
(dispatch! [::events/navigate :todos])
|
||||
(say! "1+2?")
|
||||
(asks! (call "c1" "add" "{}"))
|
||||
(testing "the calls are made on the tools of the page the user is on, not those of the first page"
|
||||
(is (= [:todos] (map :page (tool-calls-asked)))))
|
||||
(testing "the follow-up request follows the page too: the user may have gone elsewhere meanwhile"
|
||||
(dispatch! [::events/navigate :games])
|
||||
(dispatch! [::events/navigate :tic-tac-toe])
|
||||
(ran! ["c1" "add" "tool not available on this page: add" true])
|
||||
(is (= ["add"] (map :name (:tools (:body (last-request))))))))
|
||||
|
||||
@@ -612,7 +644,7 @@
|
||||
:tool-calls [(call "c1" "add" "{\"x\":1,\"y\":2}")]}]
|
||||
(:messages (chat))))
|
||||
(is (= :loading (:status (chat))) "the LLM still has to answer")
|
||||
(is (= [{:page :games :calls [(call "c1" "add" "{\"x\":1,\"y\":2}")] :on-results [::events/chat-tool-results]}]
|
||||
(is (= [{:page :tic-tac-toe :calls [(call "c1" "add" "{\"x\":1,\"y\":2}")] :on-results [::events/chat-tool-results]}]
|
||||
(tool-calls-asked)))
|
||||
(is (= 1 (count (requests))) "nothing is sent while the tools run"))
|
||||
(testing "another message can't be sent meanwhile"
|
||||
@@ -745,6 +777,30 @@
|
||||
(select-keys (chat) [:messages :input :status :error :pending-calls])))
|
||||
(is (= [] (tool-calls-asked)))))
|
||||
|
||||
(deftest chat-sends-results-of-earlier-turns-as-out-of-date-test
|
||||
(logged-in! [])
|
||||
(swap! rf-db/app-db assoc-in [:chat :messages]
|
||||
[{:role "user" :content "play"}
|
||||
{:role "assistant" :content "" :tool-calls [(call "old" "get_tic_tac_toe" "{}")]}
|
||||
{:role "tool" :tool-call-id "old" :name "get_tic_tac_toe" :content "{\"turn\":\"x\"}" :error? false}
|
||||
{:role "assistant" :content "Your move."}])
|
||||
(say! "that's wrong, it's my turn")
|
||||
(testing "a result from before the user's last message is no longer sent, its call and reply are"
|
||||
(let [sent (get-in (last-request) [:body :messages])]
|
||||
(is (= ["user" "assistant" "tool" "assistant" "user"] (map :role sent)))
|
||||
(is (= "old" (:tool-call-id (nth sent 2))))
|
||||
(is (str/includes? (:content (nth sent 2)) "out of date"))
|
||||
(is (not (str/includes? (:content (nth sent 2)) "\"turn\"")) "what it read is gone")
|
||||
(is (= "Your move." (:content (nth sent 3))))))
|
||||
(testing "the conversation the user sees keeps the result"
|
||||
(is (= "{\"turn\":\"x\"}" (:content (nth (:messages (chat)) 2)))))
|
||||
(testing "the results of the turn going on are sent as they are, for the chain to continue"
|
||||
(asks! (call "new" "get_tic_tac_toe" "{}"))
|
||||
(ran! ["new" "get_tic_tac_toe" "{\"turn\":\"o\"}" false])
|
||||
(let [sent (get-in (last-request) [:body :messages])]
|
||||
(is (str/includes? (:content (nth sent 2)) "out of date"))
|
||||
(is (= "{\"turn\":\"o\"}" (:content (last sent)))))))
|
||||
|
||||
(deftest chat-sends-no-orphaned-result-test
|
||||
(logged-in! [])
|
||||
;; The oldest message is the one that asked for the call whose result comes next, and it is what
|
||||
@@ -765,7 +821,7 @@
|
||||
answer (atom nil)]
|
||||
(rf/reg-event-fx ::ran (fn [_ [_ results]] (@answer results) {}))
|
||||
(model-context/register!
|
||||
[{:name "games-only" :pages #{:games} :description "d" :input-schema add-schema :handler (fn [_] "played")}
|
||||
[{:name "tic-tac-toe-only" :pages #{:tic-tac-toe} :description "d" :input-schema add-schema :handler (fn [_] "played")}
|
||||
{:name "anywhere" :description "d" :input-schema add-schema :handler (fn [_] "ok")}])
|
||||
(async done
|
||||
(tu/finish
|
||||
@@ -773,15 +829,15 @@
|
||||
#(js/Promise. (fn [resolve]
|
||||
(reset! answer resolve)
|
||||
(call-tools-fx {:page :todos
|
||||
:calls [(call "1" "games-only" "{}") (call "2" "anywhere" "{}")]
|
||||
:calls [(call "1" "tic-tac-toe-only" "{}") (call "2" "anywhere" "{}")]
|
||||
:on-results [::ran]})))
|
||||
(fn [results]
|
||||
(is (= [["tool not available on this page: games-only (it is offered on the games page)" true]
|
||||
(is (= [["tool not available on this page: tic-tac-toe-only (it is offered on the tic-tac-toe page)" true]
|
||||
["ok" false]]
|
||||
(map (juxt :content (comp boolean :error?)) results))))
|
||||
#(js/Promise. (fn [resolve]
|
||||
(reset! answer resolve)
|
||||
(call-tools-fx {:page :games :calls [(call "1" "games-only" "{}")] :on-results [::ran]})))
|
||||
(call-tools-fx {:page :tic-tac-toe :calls [(call "1" "tic-tac-toe-only" "{}")] :on-results [::ran]})))
|
||||
(fn [results] (is (= ["played"] (map :content results)))))
|
||||
done))))
|
||||
|
||||
|
||||
@@ -14,50 +14,50 @@
|
||||
|
||||
(deftest definitions-test
|
||||
(testing "nothing registered, nothing offered"
|
||||
(is (= [] (model-context/definitions :games))))
|
||||
(is (= [] (model-context/definitions :tic-tac-toe))))
|
||||
(testing "what the LLM is told is MCP's tools/list, in the order of registration, without the handlers"
|
||||
(model-context/register! [(tool "b" identity) (tool "a" identity)])
|
||||
(is (= [{:name "b" :description "b tool" :inputSchema schema}
|
||||
{:name "a" :description "a tool" :inputSchema schema}]
|
||||
(model-context/definitions :games))))
|
||||
(model-context/definitions :tic-tac-toe))))
|
||||
(testing "a tool without a description has none"
|
||||
(model-context/clear!)
|
||||
(model-context/register! [(tool "a" identity :description nil)])
|
||||
(is (= [{:name "a" :inputSchema schema}] (model-context/definitions :games)))))
|
||||
(is (= [{:name "a" :inputSchema schema}] (model-context/definitions :tic-tac-toe)))))
|
||||
|
||||
(deftest register-test
|
||||
(testing "registering a name again replaces the tool, so it is safe on hot reload"
|
||||
(model-context/register! [(tool "a" identity) (tool "b" identity)])
|
||||
(model-context/register! [(tool "a" identity :description "new") (tool "b" identity)])
|
||||
(is (= [["a" "new"] ["b" "b tool"]] (map (juxt :name :description) (model-context/definitions :games)))))
|
||||
(is (= [["a" "new"] ["b" "b tool"]] (map (juxt :name :description) (model-context/definitions :tic-tac-toe)))))
|
||||
(testing "tools of other names stay"
|
||||
(model-context/register! [(tool "c" identity)])
|
||||
(is (= ["a" "b" "c"] (map :name (model-context/definitions :games)))))
|
||||
(is (= ["a" "b" "c"] (map :name (model-context/definitions :tic-tac-toe)))))
|
||||
(testing "clear! takes them all"
|
||||
(model-context/clear!)
|
||||
(is (= [] (model-context/definitions :games)))))
|
||||
(is (= [] (model-context/definitions :tic-tac-toe)))))
|
||||
|
||||
(deftest pages-test
|
||||
(model-context/register! [(tool "everywhere" identity)
|
||||
(tool "games-only" identity :pages #{:games})
|
||||
(tool "tic-tac-toe-only" identity :pages #{:tic-tac-toe})
|
||||
(tool "todos-only" identity :pages #{:todos})
|
||||
(tool "both" identity :pages #{:games :todos})])
|
||||
(tool "both" identity :pages #{:tic-tac-toe :todos})])
|
||||
(testing "a tool without pages is offered on every page, the others only on theirs"
|
||||
(is (= ["everywhere" "games-only" "both"] (map :name (model-context/definitions :games))))
|
||||
(is (= ["everywhere" "tic-tac-toe-only" "both"] (map :name (model-context/definitions :tic-tac-toe))))
|
||||
(is (= ["everywhere" "todos-only" "both"] (map :name (model-context/definitions :todos))))
|
||||
(is (= ["everywhere"] (map :name (model-context/definitions :ai-setup)))))
|
||||
(testing "the pages are not part of what the LLM is told"
|
||||
(is (= #{:name :description :inputSchema} (set (mapcat keys (model-context/definitions :games))))))
|
||||
(is (= #{:name :description :inputSchema} (set (mapcat keys (model-context/definitions :tic-tac-toe))))))
|
||||
(testing "a page that is not known, or none, is offered the tools of no page"
|
||||
(is (= ["everywhere"] (map :name (model-context/definitions nil)))))
|
||||
(testing "registering a tool again replaces its pages too"
|
||||
(model-context/register! [(tool "games-only" identity :pages #{:todos})])
|
||||
(is (= ["everywhere" "both"] (map :name (model-context/definitions :games))))))
|
||||
(model-context/register! [(tool "tic-tac-toe-only" identity :pages #{:todos})])
|
||||
(is (= ["everywhere" "both"] (map :name (model-context/definitions :tic-tac-toe))))))
|
||||
|
||||
(defn- called
|
||||
"A promise of the result of calling `tool-name` on the JSON text `arguments`."
|
||||
[tool-name arguments]
|
||||
(model-context/call! :games tool-name arguments))
|
||||
(model-context/call! :tic-tac-toe tool-name arguments))
|
||||
|
||||
(deftest call-test
|
||||
(let [seen (atom nil)]
|
||||
@@ -104,22 +104,22 @@
|
||||
|
||||
(deftest call-pages-test
|
||||
(let [ran (atom [])]
|
||||
(model-context/register! [(tool "games-only" (fn [_] (swap! ran conj :games-only) "played") :pages #{:games})
|
||||
(model-context/register! [(tool "tic-tac-toe-only" (fn [_] (swap! ran conj :tic-tac-toe-only) "played") :pages #{:tic-tac-toe})
|
||||
(tool "everywhere" (fn [_] (swap! ran conj :everywhere) "ran"))])
|
||||
(async done
|
||||
(tu/finish
|
||||
(tu/steps
|
||||
#(model-context/call! :games "games-only" "{}")
|
||||
#(model-context/call! :tic-tac-toe "tic-tac-toe-only" "{}")
|
||||
(fn [r] (is (= {:text "played" :error? false} r) "on its page, a tool is called"))
|
||||
#(model-context/call! :todos "everywhere" "{}")
|
||||
(fn [r] (is (= {:text "ran" :error? false} r) "a tool of no page is called on any"))
|
||||
#(model-context/call! :todos "games-only" "{}")
|
||||
#(model-context/call! :todos "tic-tac-toe-only" "{}")
|
||||
(fn [r]
|
||||
(testing "a tool of another page is refused, and the LLM is told where it is offered"
|
||||
(is (= {:text "tool not available on this page: games-only (it is offered on the games page)"
|
||||
(is (= {:text "tool not available on this page: tic-tac-toe-only (it is offered on the tic-tac-toe page)"
|
||||
:error? true}
|
||||
r))))
|
||||
(fn [_] (is (= [:games-only :everywhere] @ran) "and never run")))
|
||||
(fn [_] (is (= [:tic-tac-toe-only :everywhere] @ran) "and never run")))
|
||||
done))))
|
||||
|
||||
(deftest call-failures-test
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[tbd.ui.pages :as pages]))
|
||||
|
||||
(deftest page->hash-test
|
||||
(is (= "#/games" (pages/page->hash :games)))
|
||||
(is (= "#/tic-tac-toe" (pages/page->hash :tic-tac-toe)))
|
||||
(is (= "#/ai-setup" (pages/page->hash :ai-setup))))
|
||||
|
||||
(deftest hash->page-test
|
||||
@@ -17,10 +17,10 @@
|
||||
(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))
|
||||
(is (= :tic-tac-toe pages/default-page))
|
||||
(doseq [hash [nil "" "#" "#/" "#/nowhere" "#/Todos"]]
|
||||
(is (= :games (pages/hash->page hash)) (pr-str hash)))))
|
||||
(is (= :tic-tac-toe (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 (= [:tic-tac-toe :calculator :todos :ai-setup] (mapv :id pages/pages)) "the order of the tabs")
|
||||
(is (= [:todos] (mapv :id (filter :login? pages/pages)))))
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
(deftest plain-subs-test
|
||||
(testing "a new page"
|
||||
(is (= "" (sub [::subs/operand :x])))
|
||||
(is (= :games (sub [::subs/page])))
|
||||
(is (= :tic-tac-toe (sub [::subs/page])))
|
||||
(is (= {:status :idle} (sub [::subs/result])))
|
||||
(is (= {:status :unknown} (sub [::subs/user])))
|
||||
(is (= (:todos db/default-db) (sub [::subs/todos])))
|
||||
@@ -33,13 +33,13 @@
|
||||
(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 [:todos :items] [{:id "a1" :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]))))
|
||||
(is (= [{:id 1 :title "a"}] (:items (sub [::subs/todos]))))
|
||||
(is (= [{:id "a1" :title "a"}] (:items (sub [::subs/todos]))))
|
||||
(is (true? (sub [::subs/mcp-copied?])))))
|
||||
|
||||
(deftest ttt-game-test
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
come first served (a conversation that goes back and forth, as when the LLM uses tools)."
|
||||
(atom nil))
|
||||
|
||||
(defn todo-uuid
|
||||
"The id the fake server gives its `n`th todo: a UUID, as the real server's are, but one a test
|
||||
can write down."
|
||||
[n]
|
||||
(str "00000000-0000-4000-8000-" (.padStart (str n) 12 "0")))
|
||||
|
||||
(defn- respond [status body]
|
||||
{:status status :body (pr-str body)})
|
||||
|
||||
@@ -48,7 +54,7 @@
|
||||
|
||||
(defn- route [method uri body]
|
||||
(let [{:keys [user todos]} @server
|
||||
todo-id (some->> (re-find #"^/api/todos/(\d+)$" uri) second js/parseInt)]
|
||||
todo-id (some->> (re-find #"^/api/todos/([0-9a-f-]{36})$" uri) second)]
|
||||
(cond
|
||||
(= "/auth/me" uri) (if user (respond 200 user) (respond 401 {:error "not logged in"}))
|
||||
(and (= "POST" method) (= "/auth/logout" uri)) (do (swap! server assoc :user nil)
|
||||
@@ -73,7 +79,7 @@
|
||||
(and (= "GET" method) (= "/api/todos" uri)) (respond 200 {:todos todos})
|
||||
(and (= "POST" method) (= "/api/todos" uri))
|
||||
(if (seq (.trim (:title body)))
|
||||
(let [todo {:id (:next-id @server) :owner-id 7 :title (:title body) :done false
|
||||
(let [todo {:id (todo-uuid (:next-id @server)) :owner-id 7 :title (:title body) :done false
|
||||
:created-at 1 :updated-at 1}]
|
||||
(swap! server #(-> % (update :todos conj todo) (update :next-id inc)))
|
||||
(respond 201 todo))
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
#(tu/eventually todos-section)
|
||||
#(tu/eventually (fn [] (or (q "ul li") (= "Nothing to do." (text "section > p")))))))
|
||||
|
||||
(def ^:private todo-1 {:id 1 :owner-id 7 :title "buy milk" :done false :created-at 1 :updated-at 1})
|
||||
(def ^:private todo-2 {:id 2 :owner-id 7 :title "walk dog" :done true :created-at 2 :updated-at 2})
|
||||
(def ^:private todo-1 {:id (tu/todo-uuid 1) :owner-id 7 :title "buy milk" :done false :created-at 1 :updated-at 1})
|
||||
(def ^:private todo-2 {:id (tu/todo-uuid 2) :owner-id 7 :title "walk dog" :done true :created-at 2 :updated-at 2})
|
||||
|
||||
;; --- The page as a visitor sees it ---
|
||||
|
||||
@@ -68,8 +68,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 (= ["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")))
|
||||
(is (= ["Tic-tac-toe" "Calculator" "AI setup"] (tab-titles)) "no Todos tab without a login")
|
||||
(is (= ["Tic-tac-toe"] (mapv #(.-textContent %) (q-all "h2"))) "the first page is tic-tac-toe")))
|
||||
done)))
|
||||
|
||||
(deftest login-state-error-test
|
||||
@@ -92,7 +92,7 @@
|
||||
(async done
|
||||
(tu/finish
|
||||
(steps
|
||||
open-page!
|
||||
#(open-page! :calculator)
|
||||
#(tu/eventually (fn [] (q "input[name=x]")))
|
||||
(fn []
|
||||
(is (nil? (q "form [role=alert]")))
|
||||
@@ -203,7 +203,7 @@
|
||||
(tu/eventually (fn [] (= ["walk dog"] (todo-titles)))))
|
||||
(fn [_]
|
||||
(is (= ["walk dog"] (todo-titles)))
|
||||
(is (= [2] (mapv :id (:todos @tu/server))))
|
||||
(is (= [(:id todo-2)] (mapv :id (:todos @tu/server))))
|
||||
(click! (tu/by-label "Delete walk dog"))
|
||||
(tu/eventually (fn [] (= "Nothing to do." (text "section > p")))))
|
||||
(fn [emptied]
|
||||
@@ -403,11 +403,18 @@
|
||||
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 (= "Tic-tac-toe" (current-tab)) "it starts on the first tab")
|
||||
(is (= "#/tic-tac-toe" (.getAttribute (q "nav a") "href")))
|
||||
(is (some? (q "[role=status]")) "the game is on the page")
|
||||
(is (nil? (q "input[name=x]")) "and the calculator is not: it has a page of its own")
|
||||
(is (nil? (q "details")) "nothing is folded away")
|
||||
(click! (second (q-all "nav a"))))
|
||||
#(tu/eventually (fn [] (= "Calculator" (current-tab))))
|
||||
(fn []
|
||||
(is (= "#/calculator" js/location.hash))
|
||||
(is (nil? (q "[role=status]")) "the game is gone")
|
||||
(is (some? (q "input[name=x]")) "the calculator is on the page")
|
||||
(click! (last (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")
|
||||
@@ -415,8 +422,8 @@
|
||||
(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))))
|
||||
(set! js/location.hash "#/tic-tac-toe"))
|
||||
#(tu/eventually (fn [] (= "Tic-tac-toe" (current-tab))))
|
||||
(fn [shown]
|
||||
(is shown "following the address (Back, Forward, a typed one) shows that page")
|
||||
(is (some? (q "[role=status]")))))
|
||||
@@ -431,7 +438,7 @@
|
||||
(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))))
|
||||
#(tu/eventually (fn [] (= "Tic-tac-toe" (current-tab))))
|
||||
(fn [shown]
|
||||
(is shown "an address that names no page shows the first one")))
|
||||
done)))
|
||||
@@ -442,13 +449,13 @@
|
||||
(steps
|
||||
#(log-in-page! [])
|
||||
(fn []
|
||||
(is (= ["Games" "Todos" "AI setup"] (tab-titles)))
|
||||
(is (= ["Tic-tac-toe" "Calculator" "Todos" "AI setup"] (tab-titles)))
|
||||
(is (= "Todos" (current-tab)))
|
||||
(click! (q "nav a"))
|
||||
(tu/eventually (fn [] (= "Games" (current-tab)))))
|
||||
(tu/eventually (fn [] (= "Tic-tac-toe" (current-tab)))))
|
||||
(fn [_]
|
||||
(is (nil? (todos-section)) "the todos are on their own page")
|
||||
(click! (second (q-all "nav a"))))
|
||||
(click! (nth (q-all "nav a") 2)))
|
||||
#(tu/eventually (fn [] (todos-section)))
|
||||
(fn [shown]
|
||||
(is shown "and back")))
|
||||
@@ -462,7 +469,7 @@
|
||||
#(open-page! :todos)
|
||||
#(tu/eventually (fn [] (some #{"Log in"} (nav-links))))
|
||||
(fn []
|
||||
(is (= ["Games" "AI setup"] (tab-titles)))
|
||||
(is (= ["Tic-tac-toe" "Calculator" "AI setup"] (tab-titles)))
|
||||
(is (nil? (todos-section)))
|
||||
(is (= "Todos" (text "section h2")))
|
||||
(is (= "Log in" (text "section p a")))
|
||||
@@ -483,6 +490,16 @@
|
||||
[]
|
||||
(mapv #(.-textContent %) (q-all "[role=log] > div[data-tool]")))
|
||||
|
||||
(defn- tool-toggle-buttons []
|
||||
(filterv #(re-find #"tool use$" (.-textContent %)) (q-all "[role=dialog] button")))
|
||||
|
||||
(defn- tool-toggle [] (first (tool-toggle-buttons)))
|
||||
|
||||
(defn- tool-toggle-labels
|
||||
"The label of the button that expands or collapses the tool use, if the panel has one."
|
||||
[]
|
||||
(mapv #(.-textContent %) (tool-toggle-buttons)))
|
||||
|
||||
(deftest chat-widget-test
|
||||
;; A green square in the bottom left corner, opposite WebMCP's blue one; it opens the panel.
|
||||
(async done
|
||||
@@ -624,7 +641,8 @@
|
||||
(resume-stream!))
|
||||
#(tu/eventually (fn [] (:resume @tu/server)))
|
||||
(fn [_]
|
||||
(is (= "Thinking…" (text "[role=status]")) "still thinking while the model reasons")
|
||||
(is (re-matches #"Thinking(\.{1,3}|…)" (text "[role=status]"))
|
||||
"still thinking while the model reasons")
|
||||
(is (not (str/includes? (panel-text) "Let me think.")) "and what it reasons is not shown")
|
||||
(is (= ["hi"] (chat-messages)) "it hasn't said anything")
|
||||
(is (true? (.-readOnly (q "input[name=message]"))) "and it isn't over")
|
||||
@@ -643,6 +661,32 @@
|
||||
(is (nil? (q "[role=dialog] [role=alert]")))))
|
||||
done)))
|
||||
|
||||
(deftest chat-thinking-animation-test
|
||||
;; While pieces of the reasoning are coming in "Thinking" has one to three dots; once they stop
|
||||
;; (for a moment) it is "Thinking…" again.
|
||||
(async done
|
||||
(swap! tu/server assoc :chat-script [:pause
|
||||
{:thinking "Let me "} {:thinking "think."} :pause
|
||||
{:content "Hi"} {:done true}])
|
||||
(tu/finish
|
||||
(tu/steps
|
||||
#(log-in-page! [])
|
||||
(fn [] (click! (q "button[aria-label=Chat]")))
|
||||
#(send-chat! "hi")
|
||||
#(tu/eventually (fn [] (and (:resume @tu/server) (q "[role=status]"))))
|
||||
(fn [_]
|
||||
(is (= "Thinking…" (text "[role=status]")) "not animated before any reasoning came")
|
||||
(resume-stream!))
|
||||
#(tu/eventually (fn [] (re-matches #"Thinking\.{1,3}" (text "[role=status]"))))
|
||||
(fn [frame]
|
||||
(is (some? frame) "animated while the reasoning is coming in"))
|
||||
#(tu/eventually (fn [] (= "Thinking…" (text "[role=status]"))))
|
||||
(fn [_]
|
||||
(is (= "Thinking…" (text "[role=status]")) "the reasoning stopped coming")
|
||||
(resume-stream!))
|
||||
#(tu/eventually (fn [] (nil? (q "[role=status]")))))
|
||||
done)))
|
||||
|
||||
(deftest chat-reasoning-is-never-shown-test
|
||||
;; Neither while it comes nor in the finished conversation, in any element of the panel.
|
||||
(async done
|
||||
@@ -777,15 +821,15 @@
|
||||
(tu/finish
|
||||
(-> (tu/steps
|
||||
#(log-in-page! [])
|
||||
;; The calculator's tools are offered on the games page.
|
||||
(fn [] (rf/dispatch-sync [::events/navigate :games]))
|
||||
;; The calculator's tools are offered on the calculator page.
|
||||
(fn [] (rf/dispatch-sync [::events/navigate :calculator]))
|
||||
(fn [] (click! (q "button[aria-label=Chat]")))
|
||||
#(send-chat! "what is 2 + 3? use the calculator")
|
||||
#(tu/eventually (fn [] (= ["what is 2 + 3? use the calculator" "I'll add them." "2 + 3 is 5."]
|
||||
(chat-messages))))
|
||||
(fn [_]
|
||||
(testing "the LLM was told about the tools of the page it was on (the games), not all of them"
|
||||
(is (= (set (map :name (filter #(contains? (or (:pages %) #{:games}) :games) webmcp/tools)))
|
||||
(testing "the LLM was told about the tools of the page it was on (the calculator), not all of them"
|
||||
(is (= (set (map :name (filter #(contains? (or (:pages %) #{:calculator}) :calculator) webmcp/tools)))
|
||||
(set (map :name (:tools (first (:chat-requests @tu/server))))))))
|
||||
(testing "the page made the call, as a user would: the calculator holds what was typed and its sum"
|
||||
(is (= {:x "2" :y "3" :result {:status :ok :x "2" :y "3" :sum 5}}
|
||||
@@ -793,12 +837,13 @@
|
||||
(testing "the call and its result show in the conversation, between what was said"
|
||||
(is (= ["Calling add {\"x\":2,\"y\":3}…" "add → {\"x\":2,\"y\":3,\"sum\":5}"]
|
||||
(chat-tool-lines)))
|
||||
(testing "the lines are shown whole: wrapped, not cut off"
|
||||
(testing "each is one line, cut off with an ellipsis where it doesn't fit, until expanded"
|
||||
(is (= ["Expand tool use"] (tool-toggle-labels)))
|
||||
(doseq [line (q-all "[data-tool]")]
|
||||
(is (= "pre-wrap" (.. line -style -whiteSpace)))
|
||||
(is (= "" (.. line -style -textOverflow)))
|
||||
(is (not= "hidden" (.. line -style -overflow)))
|
||||
(is (nil? (.getAttribute line "title")) "no tooltip: there is nothing hidden")))
|
||||
(is (= "nowrap" (.. line -style -whiteSpace)))
|
||||
(is (= "ellipsis" (.. line -style -textOverflow)))
|
||||
(is (= "hidden" (.. line -style -overflow)))
|
||||
(is (= (.-textContent line) (.getAttribute line "title")) "the whole of it is in the tooltip")))
|
||||
(is (= ["user" "assistant" "tool" "assistant"] (map :role (:messages (:chat @rf-db/app-db))))))
|
||||
(testing "the second request took the result to the LLM"
|
||||
(is (= [{:role "user" :content "what is 2 + 3? use the calculator"}
|
||||
@@ -807,7 +852,22 @@
|
||||
{:role "tool" :tool-call-id "call_1" :content "{\"x\":2,\"y\":3,\"sum\":5}"}]
|
||||
(:messages (second (:chat-requests @tu/server))))))
|
||||
(testing "and it is over: the panel takes messages again"
|
||||
(is (false? (.-readOnly (q "input[name=message]")))))))
|
||||
(is (false? (.-readOnly (q "input[name=message]")))))
|
||||
(click! (tool-toggle)))
|
||||
#(tu/eventually (fn [] (= ["Collapse tool use"] (tool-toggle-labels))))
|
||||
(fn [_]
|
||||
(testing "expanded, the lines are shown whole: wrapped, not cut off"
|
||||
(doseq [line (q-all "[data-tool]")]
|
||||
(is (= "pre-wrap" (.. line -style -whiteSpace)))
|
||||
(is (= "" (.. line -style -textOverflow)))
|
||||
(is (not= "hidden" (.. line -style -overflow)))
|
||||
(is (nil? (.getAttribute line "title")) "no tooltip: there is nothing hidden"))
|
||||
(is (= ["Calling add {\"x\":2,\"y\":3}…" "add → {\"x\":2,\"y\":3,\"sum\":5}"]
|
||||
(chat-tool-lines))))
|
||||
(click! (tool-toggle)))
|
||||
#(tu/eventually (fn [] (= ["Expand tool use"] (tool-toggle-labels))))
|
||||
(fn [_]
|
||||
(is (= "nowrap" (.. (q "[data-tool]") -style -whiteSpace)) "collapsed again")))
|
||||
(.finally model-context/clear!))
|
||||
done)))
|
||||
|
||||
@@ -823,8 +883,8 @@
|
||||
(tu/finish
|
||||
(-> (tu/steps
|
||||
#(log-in-page! [])
|
||||
;; The calculator's tools are offered on the games page.
|
||||
(fn [] (rf/dispatch-sync [::events/navigate :games]))
|
||||
;; The calculator's tools are offered on the calculator page.
|
||||
(fn [] (rf/dispatch-sync [::events/navigate :calculator]))
|
||||
(fn [] (click! (q "button[aria-label=Chat]")))
|
||||
#(send-chat! "add two and three")
|
||||
#(tu/eventually (fn [] (= ["add two and three" "Sorry, that didn't work."] (chat-messages))))
|
||||
|
||||
+238
-38
@@ -1,7 +1,9 @@
|
||||
(ns tbd.ui.webmcp-test
|
||||
(:require [cljs.test :refer [deftest is testing async use-fixtures]]
|
||||
[clojure.string :as str]
|
||||
[re-frame.core :as rf]
|
||||
[re-frame.db :as rf-db]
|
||||
[tbd.agent.prompt :as prompt]
|
||||
[tbd.ui.events :as events]
|
||||
[tbd.ui.test-util :as tu]
|
||||
[tbd.ui.webmcp :as webmcp]))
|
||||
@@ -12,17 +14,37 @@
|
||||
"What the page registered with WebMCP: name -> {:description :input-schema :execute}."
|
||||
(atom {}))
|
||||
|
||||
(def ^:private registered-prompts
|
||||
"What the page registered as prompts: name -> {:description :arguments :execute}."
|
||||
(atom {}))
|
||||
|
||||
(def ^:private registered-resources
|
||||
"What the page registered as resources: name -> {:description :options :provide}."
|
||||
(atom {}))
|
||||
|
||||
(def ^:private fake-mcp
|
||||
"A WebMCP instance, as far as the page uses it: registerTool(name, description, schema, execute)."
|
||||
"A WebMCP instance, as far as the page uses it: registerTool(name, description, schema, execute),
|
||||
registerPrompt(name, description, arguments, execute) and registerResource(name, description,
|
||||
options, provide)."
|
||||
#js {:registerTool (fn [name description input-schema execute]
|
||||
(swap! registered assoc name {:description description
|
||||
:input-schema input-schema
|
||||
:execute execute}))})
|
||||
:execute execute}))
|
||||
:registerPrompt (fn [name description arguments execute]
|
||||
(swap! registered-prompts assoc name {:description description
|
||||
:arguments arguments
|
||||
:execute execute}))
|
||||
:registerResource (fn [name description options provide]
|
||||
(swap! registered-resources assoc name {:description description
|
||||
:options options
|
||||
:provide provide}))})
|
||||
|
||||
(use-fixtures :each
|
||||
tu/url-fixture
|
||||
{:before (fn []
|
||||
(reset! registered {})
|
||||
(reset! registered-prompts {})
|
||||
(reset! registered-resources {})
|
||||
((:before tu/fake-server-fixture)))
|
||||
:after (:after tu/fake-server-fixture)})
|
||||
|
||||
@@ -59,7 +81,8 @@
|
||||
(is (fn? execute) tool-name))
|
||||
(testing "what the tools need, as the agent sees it"
|
||||
(is (= ["x" "y"] (vec (.. ^js (:input-schema (get @registered "add")) -required))))
|
||||
(is (= ["id" "done"] (vec (.. ^js (:input-schema (get @registered "set_todo_done")) -required))))))
|
||||
(is (= ["todos"] (vec (.. ^js (:input-schema (get @registered "set_todo_done")) -required))))
|
||||
(is (= ["id" "done"] (vec (.. ^js (:input-schema (get @registered "set_todo_done")) -properties -todos -items -required))))))
|
||||
|
||||
(deftest registering-again-replaces-the-tools
|
||||
(webmcp/register-tools! fake-mcp)
|
||||
@@ -142,7 +165,7 @@
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (re-find #"not logged in" (:text r)))
|
||||
(call "create_todo" {:title "buy milk"})))
|
||||
(call "create_todo" {:todos [{:title "buy milk"}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (empty? (:todos @tu/server)) "nothing was sent")
|
||||
@@ -154,7 +177,7 @@
|
||||
|
||||
(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}]
|
||||
(swap! tu/server assoc :todos [{:id (tu/todo-uuid 1) :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}]
|
||||
:next-id 2)
|
||||
(open-page!)
|
||||
(async done
|
||||
@@ -166,31 +189,104 @@
|
||||
(is (= :ai-setup (page)) "reading changes nothing")
|
||||
(call "move_tic_tac_toe" {:cell 4 :player "x"})))
|
||||
(.then (fn [_]
|
||||
(is (= :games (page)))
|
||||
(is (= :tic-tac-toe (page)))
|
||||
(rf/dispatch-sync [::events/navigate :ai-setup])
|
||||
(call "new_tic_tac_toe_game" {})))
|
||||
(.then (fn [_]
|
||||
(is (= :games (page)))
|
||||
(is (= :tic-tac-toe (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")
|
||||
(is (= :calculator (page)) "the calculator is on its own page")
|
||||
(call "list_todos" {})))
|
||||
(.then (fn [_]
|
||||
(is (= :games (page)))
|
||||
(call "create_todo" {:title "buy milk"})))
|
||||
(is (= :calculator (page)))
|
||||
(call "create_todo" {:todos [{:title "buy milk"}]})))
|
||||
(.then (fn [_]
|
||||
(is (= :todos (page)))
|
||||
(rf/dispatch-sync [::events/navigate :games])
|
||||
(call "set_todo_done" {:id 1 :done true})))
|
||||
(rf/dispatch-sync [::events/navigate :tic-tac-toe])
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 1) :done true}]})))
|
||||
(.then (fn [_]
|
||||
(is (= :todos (page)))
|
||||
(rf/dispatch-sync [::events/navigate :games])
|
||||
(call "delete_todo" {:id 1})))
|
||||
(rf/dispatch-sync [::events/navigate :tic-tac-toe])
|
||||
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [_]
|
||||
(is (= :todos (page)))
|
||||
(done))))))
|
||||
|
||||
(deftest set-todo-done-in-bulk
|
||||
(tu/log-in!)
|
||||
(swap! tu/server assoc :todos (mapv #(hash-map :id (tu/todo-uuid %) :owner-id 7 :title (str "todo-" %)
|
||||
:done true :created-at 1 :updated-at 1)
|
||||
(range 1 4))
|
||||
:next-id 4)
|
||||
(open-page!)
|
||||
(async done
|
||||
(-> (call "whoami" {})
|
||||
(.then (fn [_] (call "set_todo_done" {:todos (mapv #(hash-map :id (tu/todo-uuid %) :done false) (range 1 4))})))
|
||||
(.then (fn [r]
|
||||
(is (false? (:error? r)))
|
||||
(is (= ["todo-1" "todo-2" "todo-3"] (map :title (json (:text r)))))
|
||||
(is (= [false false false] (map :done (json (:text r)))))
|
||||
(is (= [false false false] (map :done (get-in @rf-db/app-db [:todos :items]))))
|
||||
(is (= :todos (page)))
|
||||
;; A bad entry, or an unknown todo, refuses the whole batch before anything changes.
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 1) :done true} {:id (tu/todo-uuid 99) :done true}]})))
|
||||
(.then (fn [r]
|
||||
(is (= {:text (str "todo not found: " (tu/todo-uuid 99)) :error? true} r))
|
||||
(is (= [false false false] (map :done (get-in @rf-db/app-db [:todos :items]))))
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 1) :done true} {:id (tu/todo-uuid 2) :done "yes"}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (= [false false false] (map :done (get-in @rf-db/app-db [:todos :items]))))
|
||||
(call "set_todo_done" {:todos []})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r) "an empty list is refused")
|
||||
(call "set_todo_done" {:id (tu/todo-uuid 1) :done true})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r) "the old one-todo arguments are refused")
|
||||
(done))))))
|
||||
|
||||
(deftest create-get-and-delete-todos-in-bulk
|
||||
(tu/log-in!)
|
||||
(open-page!)
|
||||
(async done
|
||||
(-> (call "whoami" {})
|
||||
(.then (fn [_] (call "create_todo" {:todos [{:title "milk"} {:title "eggs"} {:title "bread"}]})))
|
||||
(.then (fn [r]
|
||||
(is (false? (:error? r)))
|
||||
(is (= ["milk" "eggs" "bread"] (map :title (json (:text r)))) "in the order given")
|
||||
(is (= ["milk" "eggs" "bread"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||
(is (= :todos (page)))
|
||||
(call "get_todo" {:todos [{:id (tu/todo-uuid 3)} {:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= ["bread" "milk"] (map :title (json (:text r)))))
|
||||
;; A blank title refuses the batch before the first todo is added.
|
||||
(call "create_todo" {:todos [{:title "jam"} {:title ""}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (= 3 (count (:todos @tu/server))) "nothing was sent")
|
||||
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)} {:id (tu/todo-uuid 99)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= {:text (str "todo not found: " (tu/todo-uuid 99)) :error? true} r))
|
||||
(is (= 3 (count (get-in @rf-db/app-db [:todos :items]))) "an unknown id deletes nothing")
|
||||
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)} {:id (tu/todo-uuid 1)} {:id (tu/todo-uuid 2)}]})))
|
||||
(.then (fn [r]
|
||||
(is (false? (:error? r)))
|
||||
(is (= {:deleted [(tu/todo-uuid 1) (tu/todo-uuid 2)]} (json (:text r))) "a repeated id is deleted once")
|
||||
(is (= ["bread"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||
(is (= ["bread"] (map :title (:todos @tu/server))))
|
||||
;; The server refuses a blank title, which the page only sees after the first was added.
|
||||
(call "create_todo" {:todos [{:title "jam"} {:title " "} {:title "tea"}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (re-find #"\(the first 1 of 3 were done\)$" (:text r)))
|
||||
(is (= ["bread" "jam"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||
(call "delete_todo" {:id (tu/todo-uuid 3)})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r) "the one-todo arguments are refused")
|
||||
(done))))))
|
||||
|
||||
(deftest navigate
|
||||
(open-page!)
|
||||
(async done
|
||||
@@ -200,14 +296,14 @@
|
||||
(is (= {:text "{\"page\":\"ai-setup\",\"title\":\"AI setup\"}" :error? false} r))
|
||||
(is (= :ai-setup (page)) "the page is shown, as after a click on its tab")
|
||||
(is (= "#/ai-setup" js/location.hash) "and the URL follows")
|
||||
(call "navigate" {:page "games"})))
|
||||
(call "navigate" {:page "tic-tac-toe"})))
|
||||
(.then (fn [r]
|
||||
(is (false? (:error? r)))
|
||||
(is (= :games (page)))
|
||||
(is (= :tic-tac-toe (page)))
|
||||
(call "navigate" {:page "nowhere"})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (re-find #"page must be one of games, todos, ai-setup" (:text r)))
|
||||
(is (re-find #"page must be one of tic-tac-toe, calculator, todos, ai-setup" (:text r)))
|
||||
(call "navigate" {:page "ai-setup"})))
|
||||
(.then (fn [_]
|
||||
(call "navigate" {:page 5})))
|
||||
@@ -227,7 +323,7 @@
|
||||
(.then (fn [r]
|
||||
(is (:error? r) "the tab isn't there for someone who is not logged in")
|
||||
(is (re-find #"not logged in" (:text r)))
|
||||
(is (= :games (page)))
|
||||
(is (= :tic-tac-toe (page)))
|
||||
(done))))))
|
||||
|
||||
(deftest navigate-to-the-todos-when-logged-in
|
||||
@@ -245,15 +341,15 @@
|
||||
(open-page!)
|
||||
(async done
|
||||
(-> (page-loaded)
|
||||
(.then (fn [_] (call "create_todo" {:title "buy milk"})))
|
||||
(.then (fn [_] (call "create_todo" {:todos [{:title "buy milk"}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r) "not logged in")
|
||||
(is (= :games (page)) "so there is no todos page to go to")
|
||||
(is (= :tic-tac-toe (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}]
|
||||
(swap! tu/server assoc :todos [{:id (tu/todo-uuid 1) :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}]
|
||||
:next-id 2)
|
||||
(open-page!)
|
||||
(async done
|
||||
@@ -263,39 +359,40 @@
|
||||
(call "list_todos" {})))
|
||||
(.then (fn [r]
|
||||
(is (= ["old"] (map :title (json (:text r)))))
|
||||
(call "get_todo" {:id 1})))
|
||||
(call "get_todo" {:todos [{:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= "old" (:title (json (:text r)))))
|
||||
(call "get_todo" {:id 99})))
|
||||
(is (= ["old"] (map :title (json (:text r)))))
|
||||
(call "get_todo" {:todos [{:id (tu/todo-uuid 99)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= {:text "todo not found" :error? true} r))
|
||||
(call "create_todo" {:title "buy milk"})))
|
||||
(is (= {:text (str "todo not found: " (tu/todo-uuid 99)) :error? true} r))
|
||||
(call "create_todo" {:todos [{:title "buy milk"}]})))
|
||||
(.then (fn [r]
|
||||
(is (false? (:error? r)))
|
||||
(is (= {:id 2 :title "buy milk" :done false} (select-keys (json (:text r)) [:id :title :done])))
|
||||
(is (= [{:id (tu/todo-uuid 2) :title "buy milk" :done false}]
|
||||
(map #(select-keys % [:id :title :done]) (json (:text r)))))
|
||||
;; The page shows it, and the field is empty again.
|
||||
(is (= ["old" "buy milk"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||
(is (= "" (get-in @rf-db/app-db [:todos :new-title])))
|
||||
(call "set_todo_done" {:id 2 :done true})))
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 2) :done true}]})))
|
||||
(.then (fn [r]
|
||||
(is (true? (:done (json (:text r)))))
|
||||
(is (= [true] (map :done (json (:text r)))))
|
||||
(is (true? (:done (last (get-in @rf-db/app-db [:todos :items])))))
|
||||
;; Already ticked: nothing to do, and no request.
|
||||
(swap! tu/server assoc :todos [])
|
||||
(call "set_todo_done" {:id 2 :done true})))
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 2) :done true}]})))
|
||||
(.then (fn [r]
|
||||
(is (true? (:done (json (:text r)))))
|
||||
(call "set_todo_done" {:id 2 :done "yes"})))
|
||||
(is (= [true] (map :done (json (:text r)))))
|
||||
(call "set_todo_done" {:todos [{:id (tu/todo-uuid 2) :done "yes"}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(call "delete_todo" {:id 1})))
|
||||
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= {:text "deleted" :error? false} r))
|
||||
(is (= {:text (str "{\"deleted\":[\"" (tu/todo-uuid 1) "\"]}") :error? false} r))
|
||||
(is (= ["buy milk"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||
(call "delete_todo" {:id 1})))
|
||||
(call "delete_todo" {:todos [{:id (tu/todo-uuid 1)}]})))
|
||||
(.then (fn [r]
|
||||
(is (= {:text "todo not found" :error? true} r))
|
||||
(call "get_todo" {:id 1.5})))
|
||||
(is (= {:text (str "todo not found: " (tu/todo-uuid 1)) :error? true} r))
|
||||
(call "get_todo" {:todos [{:id 42}]})))
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(done))))))
|
||||
@@ -304,7 +401,7 @@
|
||||
(tu/log-in!)
|
||||
(open-page!)
|
||||
(async done
|
||||
(-> (call "create_todo" {:title " "})
|
||||
(-> (call "create_todo" {:todos [{:title " "}]})
|
||||
(.then (fn [r]
|
||||
(is (:error? r))
|
||||
(is (re-find #"Invalid body" (:text r)))
|
||||
@@ -367,3 +464,106 @@
|
||||
(.then (fn [r]
|
||||
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn "x"} (json (:text r))))
|
||||
(done))))))
|
||||
|
||||
;; --- Prompts and resources ---
|
||||
|
||||
(defn- prompt-text
|
||||
"What the prompt `prompt-name` gives, on `args` (a map, or nil for none), as a client gets it:
|
||||
{:description :role :type :text} of its one message."
|
||||
[prompt-name args]
|
||||
(let [^js result ((:execute (get @registered-prompts prompt-name)) (when args (clj->js args)))
|
||||
^js message (.. result -messages (at 0))]
|
||||
{:description (.-description result)
|
||||
:role (.-role message)
|
||||
:type (.. message -content -type)
|
||||
:text (.. message -content -text)}))
|
||||
|
||||
(defn- resource-contents
|
||||
"A promise of what the resource `resource-name` gives for `uri`, as {:uri :mime-type :text} of
|
||||
its one piece of content."
|
||||
[resource-name uri]
|
||||
(-> ((:provide (get @registered-resources resource-name)) uri)
|
||||
(.then (fn [^js result]
|
||||
(let [^js content (.. result -contents (at 0))]
|
||||
{:uri (.-uri content) :mime-type (.-mimeType content) :text (.-text content)})))))
|
||||
|
||||
(deftest registers-the-prompts
|
||||
(is (identical? fake-mcp (webmcp/register-prompts! fake-mcp)))
|
||||
(is (= (set (map :name webmcp/prompts)) (set (keys @registered-prompts))))
|
||||
(doseq [[prompt-name {:keys [description arguments execute]}] @registered-prompts]
|
||||
(is (and (string? description) (seq description)) prompt-name)
|
||||
(is (fn? execute) prompt-name)
|
||||
(doseq [^js argument arguments]
|
||||
(is (and (string? (.-name argument)) (string? (.-description argument))) prompt-name)
|
||||
(is (false? (.-required argument)) "an agent can start any of them with no arguments")))
|
||||
(webmcp/register-prompts! fake-mcp)
|
||||
(is (= (count webmcp/prompts) (count @registered-prompts)) "registering again replaces them"))
|
||||
|
||||
(deftest drive-the-app-prompt-is-the-shared-instructions
|
||||
(webmcp/register-prompts! fake-mcp)
|
||||
(let [{:keys [role type text description]} (prompt-text "drive_the_app" {:task "add a todo to buy milk"})]
|
||||
(is (= "user" role))
|
||||
(is (= "text" type))
|
||||
(is (seq description))
|
||||
(testing "it is the guide the chatbot's prompt is written alongside, then the request"
|
||||
(is (str/starts-with? text prompt/guide))
|
||||
(is (str/ends-with? text "The user's request: add a todo to buy milk"))
|
||||
(is (re-find #"Act, don't offer" text))))
|
||||
(testing "without a task it has the agent ask what is wanted; a client may send no arguments at all"
|
||||
(doseq [args [nil {} {:task ""}]]
|
||||
(is (re-find #"Ask the user what they want" (:text (prompt-text "drive_the_app" args)))))))
|
||||
|
||||
(deftest every-tool-the-prompts-name-exists
|
||||
(let [tool-names (set (map :name webmcp/tools))]
|
||||
(doseq [text [prompt/guide (prompt/tic-tac-toe "o") (prompt/task nil)]
|
||||
tool-name (re-seq #"\b(?:get|move|new|list|set|create|delete)_[a-z_]+|\badd\{" text)]
|
||||
(is (contains? tool-names (str/replace tool-name "{" "")) (str tool-name)))))
|
||||
|
||||
(deftest tic-tac-toe-prompt
|
||||
(webmcp/register-prompts! fake-mcp)
|
||||
(testing "the side is the one asked for, and O by default"
|
||||
(is (re-find #"as X\." (:text (prompt-text "play_tic_tac_toe" {:side "x"}))))
|
||||
(is (re-find #"as O\." (:text (prompt-text "play_tic_tac_toe" {:side "o"}))))
|
||||
(is (re-find #"as O\." (:text (prompt-text "play_tic_tac_toe" nil))))
|
||||
(is (re-find #"as O\." (:text (prompt-text "play_tic_tac_toe" {:side "z"})))))
|
||||
(testing "it says to move on the agent's own turn only"
|
||||
(is (re-find #"never move for the other side" (:text (prompt-text "play_tic_tac_toe" nil))))))
|
||||
|
||||
(deftest registers-the-resources
|
||||
(is (identical? fake-mcp (webmcp/register-resources! fake-mcp)))
|
||||
(is (= (set (map :name webmcp/resources)) (set (keys @registered-resources))))
|
||||
(doseq [[resource-name {:keys [description options provide]}] @registered-resources]
|
||||
(is (and (string? description) (seq description)) resource-name)
|
||||
(is (str/starts-with? (.-uri ^js options) "webmcp://app/") resource-name)
|
||||
(is (string? (.-mimeType ^js options)) resource-name)
|
||||
(is (fn? provide) resource-name))
|
||||
(is (= (count webmcp/resources)
|
||||
(count (set (map #(.-uri ^js (:options %)) (vals @registered-resources)))))
|
||||
"each has an address of its own"))
|
||||
|
||||
(deftest the-guide-resource
|
||||
(webmcp/register-resources! fake-mcp)
|
||||
(async done
|
||||
(-> (resource-contents "app_guide" "webmcp://app/guide")
|
||||
(.then (fn [contents]
|
||||
(is (= {:uri "webmcp://app/guide" :mime-type "text/markdown" :text prompt/guide} contents))
|
||||
(done))))))
|
||||
|
||||
(deftest the-state-resource
|
||||
(open-page!)
|
||||
(webmcp/register-resources! fake-mcp)
|
||||
(async done
|
||||
(-> (resource-contents "app_state" "webmcp://app/state")
|
||||
(.then (fn [{:keys [uri mime-type text]}]
|
||||
(is (= "webmcp://app/state" uri))
|
||||
(is (= "application/json" mime-type))
|
||||
(let [state (json text)]
|
||||
(is (= "tic-tac-toe" (:page state)) "the page the app opens on")
|
||||
(is (= ["tic-tac-toe" "calculator" "todos" "ai-setup"] (map :id (:pages state))))
|
||||
(is (= [false false true false] (map #(boolean (:needs-login %)) (:pages state))))
|
||||
(is (= {:logged-in false} (:user state)) "the fake server has no one logged in"))
|
||||
(call "navigate" {:page "calculator"})))
|
||||
(.then (fn [_] (resource-contents "app_state" "webmcp://app/state")))
|
||||
(.then (fn [{:keys [text]}]
|
||||
(is (= "calculator" (:page (json text))) "it follows the page that is open")
|
||||
(done))))))
|
||||
|
||||
Reference in New Issue
Block a user