@@ -36,6 +36,14 @@ ADMIN_USERS=
|
|||||||
# has to be approved again after a restart. BASE_URL (above) must be the address MCP clients use.
|
# has to be approved again after a restart. BASE_URL (above) must be the address MCP clients use.
|
||||||
# OAUTH_DB_PATH=data/oauth.db
|
# OAUTH_DB_PATH=data/oauth.db
|
||||||
|
|
||||||
|
# --- WebMCP origin trial ------------------------------------------------------
|
||||||
|
# Chrome's WebMCP (the UI's browser tools) is in an origin trial. Register BASE_URL's origin at
|
||||||
|
# https://developer.chrome.com/origintrials and paste the token here: the server then sends it
|
||||||
|
# as an `Origin-Trial` header on the page, and Chrome turns WebMCP on for everyone who opens it.
|
||||||
|
# The token is only good for that exact origin (scheme, host and port), and expires with the trial.
|
||||||
|
# Empty = no header (in Chrome you can still enable chrome://flags/#enable-webmcp-testing).
|
||||||
|
WEBMCP_ORIGIN_TRIAL_TOKEN=
|
||||||
|
|
||||||
# --- nREPL debug server (POST /nrepl) -----------------------------------------
|
# --- nREPL debug server (POST /nrepl) -----------------------------------------
|
||||||
# nREPL has no authentication and evaluates arbitrary code, so it listens on loopback
|
# nREPL has no authentication and evaluates arbitrary code, so it listens on loopback
|
||||||
# only by default. Changing NREPL_BIND to 0.0.0.0 exposes it to the network: only do that
|
# only by default. Changing NREPL_BIND to 0.0.0.0 exposes it to the network: only do that
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ it (plumcp only checks that required ones are present). A tool that touches a us
|
|||||||
something admin-only gets the caller from `current-user` there (`{:login :id :admin? :client-id}`),
|
something admin-only gets the caller from `current-user` there (`{:login :id :admin? :client-id}`),
|
||||||
and must not trust anything else about who is asking.
|
and must not trust anything else about who is asking.
|
||||||
|
|
||||||
|
(The UI has tools of its own for a browser agent, [see below](#browser-mcp-tools-webmcp); they are not part of this server.)
|
||||||
|
|
||||||
Sessions are kept in memory, so they end when the server restarts (clients just re-initialize; their
|
Sessions are kept in memory, so they end when the server restarts (clients just re-initialize; their
|
||||||
tokens are in the database and keep working). Server-to-client streaming (`GET /mcp`) is deliberately
|
tokens are in the database and keep working). Server-to-client streaming (`GET /mcp`) is deliberately
|
||||||
off: nothing here pushes messages, and the response would hang under Jetty. PluMCP needs JDK 21+.
|
off: nothing here pushes messages, and the response would hang under Jetty. PluMCP needs JDK 21+.
|
||||||
@@ -218,6 +220,37 @@ directory at `/js`, and serves `resources/public/index.html`, the shell that mou
|
|||||||
|
|
||||||
The Docker image builds the UI in its own stage, so `docker build` needs no local Node.
|
The Docker image builds the UI in its own stage, so `docker build` needs no local Node.
|
||||||
|
|
||||||
|
### Browser MCP tools (WebMCP)
|
||||||
|
|
||||||
|
The page also offers tools of its own to your MCP client (Claude), through [WebMCP](https://webmcp.dev):
|
||||||
|
a small library that adds a widget to the page and lets the page register tools with it. They are the browser-side twin of the
|
||||||
|
[server's MCP tools](#mcp-server): those work on your data on the server, these work *the page*, doing
|
||||||
|
what you could do with the mouse and keyboard, so you see it happen. They are in `ui/src/tbd/ui/webmcp.cljs`,
|
||||||
|
registered with `registerTool` on a `WebMCP` instance (the `@jason.today/webmcp` npm package):
|
||||||
|
|
||||||
|
| Tool | What it does |
|
||||||
|
|------|--------------|
|
||||||
|
| `ping`, `whoami` | Health check; who is logged in on the page, if anyone |
|
||||||
|
| `get_calculator` | What the calculator form holds: its two fields and the result it shows |
|
||||||
|
| `add` | Types `x` and `y` into the calculator, presses Add, and returns the sum the page shows |
|
||||||
|
| `list_todos`, `get_todo` | The todos the page shows (login required, like the todo list itself) |
|
||||||
|
| `create_todo` | Types a `title` into the new-todo field, presses Add, returns the new todo |
|
||||||
|
| `set_todo_done` | Ticks or unticks a todo's checkbox (`id`, `done`) |
|
||||||
|
| `delete_todo` | Presses a todo's Delete button |
|
||||||
|
|
||||||
|
Each tool dispatches the same re-frame events the views do and waits for the result to show up in
|
||||||
|
the app-db, so what the agent sees is what the page shows, server errors included. There is no
|
||||||
|
`update_todo` with a title as on the server, because the page can't edit one.
|
||||||
|
|
||||||
|
To connect (the page's "Browser tools (WebMCP)" section says the same):
|
||||||
|
|
||||||
|
1. Add the WebMCP server to your MCP client, once: `npx -y @jason.today/webmcp@latest --config claude`
|
||||||
|
(restart Claude Desktop afterwards).
|
||||||
|
2. Ask the client to *make a webmcp token*.
|
||||||
|
3. Click the blue square in the bottom right corner of the page, paste the token, and press Connect.
|
||||||
|
The token works once, and the page has no field for it: the widget is where it goes.
|
||||||
|
4. The tools show up in the client, prefixed with the page's host.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Every task is a bash script in `script/`. Start an nREPL server (with CIDER middleware) on the port in `.nrepl-port`, or `3000` if that
|
Every task is a bash script in `script/`. Start an nREPL server (with CIDER middleware) on the port in `.nrepl-port`, or `3000` if that
|
||||||
@@ -232,6 +265,7 @@ script/repl
|
|||||||
| Task | What it does |
|
| Task | What it does |
|
||||||
|------|--------------|
|
|------|--------------|
|
||||||
| `script/unit-test` | Runs the Clojure tests in `test/` |
|
| `script/unit-test` | Runs the Clojure tests in `test/` |
|
||||||
|
| `script/ui-test` | Runs the ClojureScript tests in `ui/test/` under Node (needs Node and a JDK; CI doesn't run it yet) |
|
||||||
| `script/integration-test` | Builds the app image, starts it on a random port, runs the [integration suite](integration-test/README.md) against it, then tears it down |
|
| `script/integration-test` | Builds the app image, starts it on a random port, runs the [integration suite](integration-test/README.md) against it, then tears it down |
|
||||||
| `script/test` | Unit tests, then integration tests (stops at the first failure) |
|
| `script/test` | Unit tests, then integration tests (stops at the first failure) |
|
||||||
| `script/docker-unit-test` | Same as `script/unit-test`, but inside the app image (Docker only) |
|
| `script/docker-unit-test` | Same as `script/unit-test`, but inside the app image (Docker only) |
|
||||||
@@ -265,7 +299,7 @@ src/tbd/
|
|||||||
oauth/ tbd as the OAuth authorization server for MCP clients: rules (core), SQLite storage (db), `/oauth` + discovery routes, and the bearer-token middleware guarding `/mcp` (middleware)
|
oauth/ tbd as the OAuth authorization server for MCP clients: rules (core), SQLite storage (db), `/oauth` + discovery routes, and the bearer-token middleware guarding `/mcp` (middleware)
|
||||||
nrepl/ On-demand nREPL server (core) and the admin-only `/nrepl` routes
|
nrepl/ On-demand nREPL server (core) and the admin-only `/nrepl` routes
|
||||||
todo/ Per-user todos: pure rules (core), Malli schemas of the input (schema), SQLite storage (db), `/api/todos` routes
|
todo/ Per-user todos: pure rules (core), Malli schemas of the input (schema), SQLite storage (db), `/api/todos` routes
|
||||||
ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,core}.cljs
|
ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,webmcp,core}.cljs, tests in ui/test/
|
||||||
.env-template Settings the server reads from the environment; copy to .env (git-ignored)
|
.env-template Settings the server reads from the environment; copy to .env (git-ignored)
|
||||||
resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored)
|
resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored)
|
||||||
test/ Unit tests
|
test/ Unit tests
|
||||||
|
|||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the UI's (ClojureScript) tests under Node. Needs Node and a JDK, like script/ui-build.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/../ui"
|
||||||
|
|
||||||
|
[[ -d node_modules ]] || npm ci
|
||||||
|
exec npx shadow-cljs compile test
|
||||||
+14
-8
@@ -1,5 +1,6 @@
|
|||||||
(ns tbd.core
|
(ns tbd.core
|
||||||
(:require [ring.adapter.jetty :as jetty]
|
(:require [clojure.string :as str]
|
||||||
|
[ring.adapter.jetty :as jetty]
|
||||||
[ring.middleware.defaults :refer [wrap-defaults api-defaults]]
|
[ring.middleware.defaults :refer [wrap-defaults api-defaults]]
|
||||||
[ring.middleware.head :refer [wrap-head]]
|
[ring.middleware.head :refer [wrap-head]]
|
||||||
[reitit.ring :as ring]
|
[reitit.ring :as ring]
|
||||||
@@ -45,10 +46,14 @@
|
|||||||
:details (:humanized (reitit.coercion/encode-error (ex-data e)))}))
|
:details (:humanized (reitit.coercion/encode-error (ex-data e)))}))
|
||||||
|
|
||||||
(defn- index-handler
|
(defn- index-handler
|
||||||
"The HTML shell for the re-frame UI (ui/); it loads the compiled JS from /js."
|
"The HTML shell for the re-frame UI (ui/); it loads the compiled JS from /js. With a WebMCP
|
||||||
[_req]
|
origin-trial token it also sends it as an `Origin-Trial` header, which is how Chrome enables the
|
||||||
(-> (response/resource-response "public/index.html")
|
trial for this origin before any script runs."
|
||||||
(response/content-type "text/html; charset=utf-8")))
|
[{:keys [origin-trial-token]}]
|
||||||
|
(fn [_req]
|
||||||
|
(cond-> (-> (response/resource-response "public/index.html")
|
||||||
|
(response/content-type "text/html; charset=utf-8"))
|
||||||
|
(seq origin-trial-token) (response/header "Origin-Trial" origin-trial-token))))
|
||||||
|
|
||||||
(defn- decode-error-handler
|
(defn- decode-error-handler
|
||||||
"A request body that isn't valid for its Content-Type -> 400, in the same shape (and format) the routes use."
|
"A request body that isn't valid for its Content-Type -> 400, in the same shape (and format) the routes use."
|
||||||
@@ -63,7 +68,7 @@
|
|||||||
|
|
||||||
;; Each logical group (health, users, orders, ...) owns a `tbd.<group>.routes`
|
;; Each logical group (health, users, orders, ...) owns a `tbd.<group>.routes`
|
||||||
;; namespace that returns its own route subtree; they're concatenated here.
|
;; namespace that returns its own route subtree; they're concatenated here.
|
||||||
(defn make-router [{:keys [auth nrepl todo oauth]}]
|
(defn make-router [{:keys [auth nrepl todo oauth webmcp]}]
|
||||||
(let [oauth-ds (delay (oauth-db/open (:db-path oauth)))
|
(let [oauth-ds (delay (oauth-db/open (:db-path oauth)))
|
||||||
;; Opened (and migrated) on first use, not when the app loads.
|
;; Opened (and migrated) on first use, not when the app loads.
|
||||||
todo-ds (delay (todo-db/open (:db-path todo)))]
|
todo-ds (delay (todo-db/open (:db-path todo)))]
|
||||||
@@ -75,7 +80,7 @@
|
|||||||
:swagger {:info {:title "tbd API" :version "1.0.0"}}
|
:swagger {:info {:title "tbd API" :version "1.0.0"}}
|
||||||
:handler (swagger-handler)}}]
|
:handler (swagger-handler)}}]
|
||||||
["/" {:get {:no-doc true
|
["/" {:get {:no-doc true
|
||||||
:handler index-handler}}]
|
:handler (index-handler webmcp)}}]
|
||||||
(health/routes)
|
(health/routes)
|
||||||
(math/routes)
|
(math/routes)
|
||||||
(mcp-routes/routes auth oauth-ds todo-ds)
|
(mcp-routes/routes auth oauth-ds todo-ds)
|
||||||
@@ -116,7 +121,8 @@
|
|||||||
{:auth (auth/env-config)
|
{:auth (auth/env-config)
|
||||||
:nrepl (nrepl/env-config)
|
:nrepl (nrepl/env-config)
|
||||||
:todo (todo-db/env-config)
|
:todo (todo-db/env-config)
|
||||||
:oauth (oauth-db/env-config)})
|
:oauth (oauth-db/env-config)
|
||||||
|
:webmcp {:origin-trial-token (some-> (System/getenv "WEBMCP_ORIGIN_TRIAL_TOKEN") str/trim not-empty)}})
|
||||||
|
|
||||||
(def app (make-app config))
|
(def app (make-app config))
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,16 @@
|
|||||||
(testing "HEAD is served like GET, without a body"
|
(testing "HEAD is served like GET, without a body"
|
||||||
(is (= 200 (:status (request :head "/"))))))
|
(is (= 200 (:status (request :head "/"))))))
|
||||||
|
|
||||||
|
(deftest origin-trial-header-test
|
||||||
|
(let [get-root (fn [config]
|
||||||
|
((core/make-app config) {:request-method :get :uri "/" :headers {}}))]
|
||||||
|
(testing "the WebMCP origin-trial token is sent as an Origin-Trial header on the page"
|
||||||
|
(is (= "tok-123" (get-in (get-root (assoc core/config :webmcp {:origin-trial-token "tok-123"}))
|
||||||
|
[:headers "Origin-Trial"]))))
|
||||||
|
(testing "no token, no header"
|
||||||
|
(is (nil? (get-in (get-root (assoc core/config :webmcp {:origin-trial-token nil}))
|
||||||
|
[:headers "Origin-Trial"]))))))
|
||||||
|
|
||||||
(deftest js-assets-test
|
(deftest js-assets-test
|
||||||
;; main.js only exists once script/ui-build has run, so only the miss is asserted here;
|
;; main.js only exists once script/ui-build has run, so only the miss is asserted here;
|
||||||
;; the integration suite (which runs against the built image) checks the hit.
|
;; the integration suite (which runs against the built image) checks the hit.
|
||||||
|
|||||||
Generated
+1301
-2
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
|||||||
"release": "shadow-cljs release app"
|
"release": "shadow-cljs release app"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@jason.today/webmcp": "0.1.13",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
"react-dom": "^19.3.0"
|
"react-dom": "^19.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
;; The UI compiles straight into the server's resources dir, so the server picks it up from
|
;; The UI compiles straight into the server's resources dir, so the server picks it up from
|
||||||
;; the classpath (see `tbd.core`, which serves `resources/public` at `/js`).
|
;; the classpath (see `tbd.core`, which serves `resources/public` at `/js`).
|
||||||
{:source-paths ["src"]
|
{:source-paths ["src" "test"]
|
||||||
|
|
||||||
:dependencies
|
:dependencies
|
||||||
[[reagent "2.0.1"]
|
[[reagent "2.0.1"]
|
||||||
@@ -11,4 +11,10 @@
|
|||||||
:output-dir "../resources/public/js"
|
:output-dir "../resources/public/js"
|
||||||
:asset-path "/js"
|
:asset-path "/js"
|
||||||
:modules {:main {:init-fn tbd.ui.core/init}}
|
:modules {:main {:init-fn tbd.ui.core/init}}
|
||||||
:devtools {:after-load tbd.ui.core/reload}}}}
|
:devtools {:after-load tbd.ui.core/reload}}
|
||||||
|
|
||||||
|
;; script/ui-test: the tests run under Node, against fakes of fetch and navigator.modelContext.
|
||||||
|
:test {:target :node-test
|
||||||
|
:output-to "target/ui-test/test.js"
|
||||||
|
:ns-regexp "-test$"
|
||||||
|
:autorun true}}}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
(:require [re-frame.core :as rf]
|
(:require [re-frame.core :as rf]
|
||||||
[reagent.dom.client :as rdc]
|
[reagent.dom.client :as rdc]
|
||||||
[tbd.ui.events :as events]
|
[tbd.ui.events :as events]
|
||||||
[tbd.ui.views :as views]))
|
[tbd.ui.views :as views]
|
||||||
|
[tbd.ui.webmcp :as webmcp]))
|
||||||
|
|
||||||
(defonce root (atom nil))
|
(defonce root (atom nil))
|
||||||
|
|
||||||
@@ -13,9 +14,11 @@
|
|||||||
"Called by shadow-cljs after each hot reload."
|
"Called by shadow-cljs after each hot reload."
|
||||||
[]
|
[]
|
||||||
(rf/clear-subscription-cache!)
|
(rf/clear-subscription-cache!)
|
||||||
|
(webmcp/register!)
|
||||||
(render))
|
(render))
|
||||||
|
|
||||||
(defn init []
|
(defn init []
|
||||||
(rf/dispatch-sync [::events/initialize])
|
(rf/dispatch-sync [::events/initialize])
|
||||||
(reset! root (rdc/create-root (js/document.getElementById "app")))
|
(reset! root (rdc/create-root (js/document.getElementById "app")))
|
||||||
|
(webmcp/register!)
|
||||||
(render))
|
(render))
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
(ns tbd.ui.views
|
(ns tbd.ui.views
|
||||||
(:require [re-frame.core :as rf]
|
(:require [re-frame.core :as rf]
|
||||||
[tbd.ui.events :as events]
|
[tbd.ui.events :as events]
|
||||||
[tbd.ui.subs :as subs]))
|
[tbd.ui.subs :as subs]
|
||||||
|
[tbd.ui.webmcp :as webmcp]))
|
||||||
|
|
||||||
(defn- operand-input [k label]
|
(defn- operand-input [k label]
|
||||||
[:label label " "
|
[:label label " "
|
||||||
@@ -74,13 +75,20 @@
|
|||||||
[]
|
[]
|
||||||
(str "claude mcp add --transport http tbd " js/location.origin "/mcp"))
|
(str "claude mcp add --transport http tbd " js/location.origin "/mcp"))
|
||||||
|
|
||||||
|
(defn- collapsible
|
||||||
|
"A section the reader can fold away by its heading (a native <details>, open to start with)."
|
||||||
|
[title & body]
|
||||||
|
(into [:details {:open true :style {:margin-top "2rem"}}
|
||||||
|
[:summary {:style {:cursor "pointer"}}
|
||||||
|
[:h2 {:style {:display "inline"}} title]]]
|
||||||
|
body))
|
||||||
|
|
||||||
(defn mcp-setup
|
(defn mcp-setup
|
||||||
"How to add this server to Claude Code: a command to copy, then signing in once."
|
"How to add this server to Claude Code: a command to copy, then signing in once."
|
||||||
[]
|
[]
|
||||||
(let [command (mcp-command)
|
(let [command (mcp-command)
|
||||||
copied? @(rf/subscribe [::subs/mcp-copied?])]
|
copied? @(rf/subscribe [::subs/mcp-copied?])]
|
||||||
[:section {:style {:margin-top "2rem"}}
|
[collapsible "Add this MCP server to Claude Code"
|
||||||
[:h2 "Add this MCP server to Claude Code"]
|
|
||||||
[:p "Run this in your terminal:"]
|
[:p "Run this in your terminal:"]
|
||||||
[:div {:style {:display "flex" :gap "0.5rem" :align-items "flex-start"}}
|
[:div {:style {:display "flex" :gap "0.5rem" :align-items "flex-start"}}
|
||||||
[:pre {:style {:flex 1 :margin 0 :padding "0.75rem" :overflow-x "auto"
|
[:pre {:style {:flex 1 :margin 0 :padding "0.75rem" :overflow-x "auto"
|
||||||
@@ -97,6 +105,22 @@
|
|||||||
". Your browser opens: log in with Gitea if asked, and click " [:strong "Approve"] "."]
|
". Your browser opens: log in with Gitea if asked, and click " [:strong "Approve"] "."]
|
||||||
[:li "Back in Claude Code, try: " [:em "\"use the tbd whoami tool\""] "."]]]))
|
[:li "Back in Claude Code, try: " [:em "\"use the tbd whoami tool\""] "."]]]))
|
||||||
|
|
||||||
|
(defn browser-tools
|
||||||
|
"The page's own tools for a browser agent (WebMCP, see tbd.ui.webmcp): how to connect an MCP
|
||||||
|
client to them. The token is pasted into the WebMCP widget in the corner of the page."
|
||||||
|
[]
|
||||||
|
[collapsible "Browser tools (WebMCP)"
|
||||||
|
[:p "This page offers " [:strong (count webmcp/tools)] " tools of its own (the calculator and the todos) to "
|
||||||
|
"your MCP client, over " [:a {:href "https://webmcp.dev" :target "_blank" :rel "noreferrer"} "WebMCP"]
|
||||||
|
", and you see them work on the page. To connect:"]
|
||||||
|
[:ol
|
||||||
|
[:li "Add the WebMCP server to your MCP client, once: " [:code "npx -y @jason.today/webmcp@latest --config claude"]
|
||||||
|
" (restart Claude Desktop afterwards)."]
|
||||||
|
[:li "Ask your MCP client: " [:em "\"make a webmcp token\""] "."]
|
||||||
|
[:li "Click the blue square in the bottom right corner of this page, paste the token, and press Connect. "
|
||||||
|
"(The token works once; to disconnect, press Disconnect there.)"]
|
||||||
|
[:li "The tools show up in your client, prefixed with this page's host."]]])
|
||||||
|
|
||||||
(defn- link-button [props label]
|
(defn- link-button [props label]
|
||||||
[:button (merge {:type "button"
|
[:button (merge {:type "button"
|
||||||
:style {:background "none" :border 0 :padding 0 :font "inherit"
|
:style {:background "none" :border 0 :padding 0 :font "inherit"
|
||||||
@@ -122,6 +146,7 @@
|
|||||||
(when (= :logged-in (:status user))
|
(when (= :logged-in (:status user))
|
||||||
[todos])
|
[todos])
|
||||||
[mcp-setup]
|
[mcp-setup]
|
||||||
|
[browser-tools]
|
||||||
[:p [:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"]
|
[:p [:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"]
|
||||||
(when-let [link (auth-link user)]
|
(when-let [link (auth-link user)]
|
||||||
[:<> " · " link])]]))
|
[:<> " · " link])]]))
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
(ns tbd.ui.webmcp
|
||||||
|
"The page's own MCP tools, for an agent running in the browser: the client-side counterpart of
|
||||||
|
tbd.mcp.tools. They are offered through WebMCP (https://webmcp.dev, the `@jason.today/webmcp`
|
||||||
|
library): it adds a small widget to the page, the user pastes a token from their MCP client
|
||||||
|
(Claude) into it, and the client then sees the tools registered here. Where tbd.mcp.tools works
|
||||||
|
on the server's data, these work the UI: each one does what a user could do on the page (type in
|
||||||
|
the form, press a button, tick a box) by dispatching the same re-frame events the views do, so
|
||||||
|
everything shows on screen, and reads what the page shows from the app-db.
|
||||||
|
|
||||||
|
A tool takes its arguments as a map and returns a value or a promise of one: a string, or data
|
||||||
|
that becomes JSON. Throwing (or rejecting) becomes an error result the agent can read. Input
|
||||||
|
schemas are JSON Schema, written here: the UI has no Malli to share with the server's."
|
||||||
|
(:require ["@jason.today/webmcp/src/webmcp.js" :as WebMCP]
|
||||||
|
[re-frame.core :as rf]
|
||||||
|
[re-frame.db :as rf-db]
|
||||||
|
[tbd.ui.events :as events]))
|
||||||
|
|
||||||
|
;; --- Reading and waiting for the app-db ---
|
||||||
|
|
||||||
|
(def ^:private timeout-ms
|
||||||
|
"How long a tool waits for the server to answer before giving up."
|
||||||
|
10000)
|
||||||
|
|
||||||
|
(defn- db [] @rf-db/app-db)
|
||||||
|
|
||||||
|
(defn- fail! [message] (throw (js/Error. message)))
|
||||||
|
|
||||||
|
(defn- settled
|
||||||
|
"A promise of the app-db as soon as `(pred db)` holds (right away if it already does), or a
|
||||||
|
rejection after `timeout-ms`. Requests made by events finish some time after the event is
|
||||||
|
dispatched, so a tool that changes something waits for the change to show up in the db."
|
||||||
|
[pred what]
|
||||||
|
(js/Promise.
|
||||||
|
(fn [resolve reject]
|
||||||
|
(if (pred (db))
|
||||||
|
(resolve (db))
|
||||||
|
(let [k (keyword "tbd.ui.webmcp" (str (gensym "await")))
|
||||||
|
timer (atom nil)
|
||||||
|
finish! (fn [f v]
|
||||||
|
(remove-watch rf-db/app-db k)
|
||||||
|
(js/clearTimeout @timer)
|
||||||
|
(f v))]
|
||||||
|
(reset! timer (js/setTimeout #(finish! reject (js/Error. (str "timed out waiting for " what)))
|
||||||
|
timeout-ms))
|
||||||
|
(add-watch rf-db/app-db k (fn [_ _ _ new-db]
|
||||||
|
(when (pred new-db) (finish! resolve new-db)))))))))
|
||||||
|
|
||||||
|
(defn- known-user
|
||||||
|
"A promise of the login state, once the page has found out what it is."
|
||||||
|
[]
|
||||||
|
(-> (settled #(not= :unknown (get-in % [:user :status])) "the login state")
|
||||||
|
(.then #(:user %))))
|
||||||
|
|
||||||
|
(defn- logged-in-todos
|
||||||
|
"A promise of the todo list as the page shows it, or a rejection if there is no login (the page
|
||||||
|
hides the todos then) or the list could not be loaded."
|
||||||
|
[]
|
||||||
|
(-> (known-user)
|
||||||
|
(.then (fn [{:keys [status message]}]
|
||||||
|
(case status
|
||||||
|
:logged-in nil
|
||||||
|
:anonymous (fail! "not logged in: log in on the page to use the todos")
|
||||||
|
:error (fail! (str "could not tell who is logged in: " message)))))
|
||||||
|
(.then #(settled (fn [d] (contains? #{:ok :error} (get-in d [:todos :status]))) "the todos"))
|
||||||
|
(.then (fn [d]
|
||||||
|
(let [{:keys [status items error]} (:todos d)]
|
||||||
|
(if (= :error status) (fail! error) items))))))
|
||||||
|
|
||||||
|
(defn- todo-failure
|
||||||
|
"Why the last change to the todos failed, or nil. A 401 drops the login, so that counts too."
|
||||||
|
[d]
|
||||||
|
(or (get-in d [:todos :error])
|
||||||
|
(when (not= :logged-in (get-in d [:user :status]))
|
||||||
|
"the login has expired: log in again on the page")))
|
||||||
|
|
||||||
|
(defn- change-todos
|
||||||
|
"Dispatches `event`, then waits for `(done? db)` (or for the change to fail, which throws why),
|
||||||
|
and returns the db."
|
||||||
|
[event done? what]
|
||||||
|
(rf/dispatch-sync event)
|
||||||
|
(-> (settled #(or (todo-failure %) (done? %)) what)
|
||||||
|
(.then (fn [d] (if-let [why (todo-failure d)] (fail! why) d)))))
|
||||||
|
|
||||||
|
(defn- find-todo [items id]
|
||||||
|
(first (filter #(= id (:id %)) items)))
|
||||||
|
|
||||||
|
(defn- find-todo! [items id]
|
||||||
|
(or (find-todo items id) (fail! "todo not found")))
|
||||||
|
|
||||||
|
;; --- Argument checks (the page can't trust that the agent honours the schema) ---
|
||||||
|
|
||||||
|
(defn- check-id! [id]
|
||||||
|
(when-not (and (number? id) (js/Number.isInteger id))
|
||||||
|
(fail! "invalid arguments: id must be an integer")))
|
||||||
|
|
||||||
|
;; --- The tools ---
|
||||||
|
|
||||||
|
(defn- ping [_] "pong")
|
||||||
|
|
||||||
|
(defn- whoami [_]
|
||||||
|
(-> (known-user)
|
||||||
|
(.then (fn [{:keys [status login message]}]
|
||||||
|
(case status
|
||||||
|
:logged-in (str "logged in as " login)
|
||||||
|
:anonymous "not logged in"
|
||||||
|
:error (str "could not tell who is logged in: " message))))))
|
||||||
|
|
||||||
|
(defn- get-calculator
|
||||||
|
[_]
|
||||||
|
(:calc (db)))
|
||||||
|
|
||||||
|
(defn- add
|
||||||
|
"Types the numbers into the calculator form and presses Add."
|
||||||
|
[{:keys [x y]}]
|
||||||
|
(when-not (and (number? x) (js/Number.isFinite x) (number? y) (js/Number.isFinite y))
|
||||||
|
(fail! "invalid arguments: x and y must be numbers"))
|
||||||
|
(when (= :loading (get-in (db) [:calc :result :status]))
|
||||||
|
(fail! "an addition is already in progress"))
|
||||||
|
(rf/dispatch-sync [::events/set-operand :x (str x)])
|
||||||
|
(rf/dispatch-sync [::events/set-operand :y (str y)])
|
||||||
|
(rf/dispatch-sync [::events/add])
|
||||||
|
(-> (settled #(not= :loading (get-in % [:calc :result :status])) "the sum")
|
||||||
|
(.then (fn [d]
|
||||||
|
(let [{:keys [status sum message]} (get-in d [:calc :result])]
|
||||||
|
(if (= :ok status)
|
||||||
|
{:x x :y y :sum sum}
|
||||||
|
(fail! message)))))))
|
||||||
|
|
||||||
|
(defn- list-todos [_]
|
||||||
|
(logged-in-todos))
|
||||||
|
|
||||||
|
(defn- get-todo [{:keys [id]}]
|
||||||
|
(check-id! id)
|
||||||
|
(-> (logged-in-todos)
|
||||||
|
(.then #(find-todo! % id))))
|
||||||
|
|
||||||
|
(defn- create-todo
|
||||||
|
"Types the title into the new-todo field and presses Add."
|
||||||
|
[{:keys [title]}]
|
||||||
|
(when-not (and (string? title) (seq title))
|
||||||
|
(fail! "invalid arguments: title must be a non-empty string"))
|
||||||
|
(-> (logged-in-todos)
|
||||||
|
(.then (fn [_]
|
||||||
|
(rf/dispatch-sync [::events/set-new-title title])
|
||||||
|
;; The field is emptied when the todo was added; a failure leaves it as it is.
|
||||||
|
(change-todos [::events/add-todo] #(= "" (get-in % [:todos :new-title])) "the new todo")))
|
||||||
|
(.then #(last (get-in % [:todos :items])))))
|
||||||
|
|
||||||
|
(defn- set-todo-done
|
||||||
|
"Ticks or unticks a todo's checkbox."
|
||||||
|
[{:keys [id done]}]
|
||||||
|
(check-id! id)
|
||||||
|
(when-not (boolean? done)
|
||||||
|
(fail! "invalid arguments: done must be true or false"))
|
||||||
|
(-> (logged-in-todos)
|
||||||
|
(.then (fn [items]
|
||||||
|
(let [todo (find-todo! items id)]
|
||||||
|
(if (= done (boolean (:done todo)))
|
||||||
|
todo
|
||||||
|
(-> (change-todos [::events/set-done id done]
|
||||||
|
#(= done (boolean (:done (find-todo (get-in % [:todos :items]) id))))
|
||||||
|
"the todo")
|
||||||
|
(.then #(find-todo (get-in % [:todos :items]) id)))))))))
|
||||||
|
|
||||||
|
(defn- delete-todo
|
||||||
|
"Presses a todo's Delete button."
|
||||||
|
[{:keys [id]}]
|
||||||
|
(check-id! id)
|
||||||
|
(-> (logged-in-todos)
|
||||||
|
(.then (fn [items]
|
||||||
|
(find-todo! items id)
|
||||||
|
(change-todos [::events/delete-todo id]
|
||||||
|
#(nil? (find-todo (get-in % [:todos :items]) id))
|
||||||
|
"the todo to go")))
|
||||||
|
(.then (fn [_] "deleted"))))
|
||||||
|
|
||||||
|
(defn- object-schema [properties required]
|
||||||
|
{:type "object" :properties properties :required required})
|
||||||
|
|
||||||
|
(def ^:private todo-id {:type "integer" :description "The todo's id"})
|
||||||
|
|
||||||
|
(def tools
|
||||||
|
"The tools, as {:name :description :input-schema :handler}."
|
||||||
|
[{:name "ping" :description "Health check: returns pong"
|
||||||
|
:input-schema (object-schema {} []) :handler ping}
|
||||||
|
|
||||||
|
{:name "whoami"
|
||||||
|
:description "Says who is logged in on this page, if anyone"
|
||||||
|
:input-schema (object-schema {} []) :handler whoami}
|
||||||
|
|
||||||
|
{:name "get_calculator"
|
||||||
|
:description (str "What the calculator form on this page holds (JSON): the x and y in its fields, and "
|
||||||
|
"the result it is showing (status idle, loading, ok with the sum, or error with the message)")
|
||||||
|
:input-schema (object-schema {} []) :handler get-calculator}
|
||||||
|
|
||||||
|
{:name "add"
|
||||||
|
:description "Types two numbers into the calculator form, presses Add, and returns the sum the page shows (JSON)"
|
||||||
|
:input-schema (object-schema {:x {:type "number" :description "The first number"}
|
||||||
|
:y {:type "number" :description "The second number"}}
|
||||||
|
["x" "y"])
|
||||||
|
:handler add}
|
||||||
|
|
||||||
|
{:name "list_todos"
|
||||||
|
:description (str "Lists the todos this page shows (JSON: id, title, done, created-at, updated-at), oldest first. "
|
||||||
|
"Needs a login on the page")
|
||||||
|
:input-schema (object-schema {} []) :handler list-todos}
|
||||||
|
|
||||||
|
{:name "get_todo"
|
||||||
|
:description "Gets one of the todos this page shows, by id (JSON)"
|
||||||
|
:input-schema (object-schema {:id todo-id} ["id"]) :handler get-todo}
|
||||||
|
|
||||||
|
{:name "create_todo"
|
||||||
|
:description "Types a title into the new-todo field, presses Add, and returns the todo that was created (JSON)"
|
||||||
|
:input-schema (object-schema {:title {:type "string" :minLength 1 :maxLength 200
|
||||||
|
:description "What needs doing"}}
|
||||||
|
["title"])
|
||||||
|
:handler create-todo}
|
||||||
|
|
||||||
|
{:name "set_todo_done"
|
||||||
|
:description "Ticks or unticks a todo's checkbox and returns the todo (JSON)"
|
||||||
|
:input-schema (object-schema {:id todo-id
|
||||||
|
:done {:type "boolean" :description "true to tick it off, false to untick it"}}
|
||||||
|
["id" "done"])
|
||||||
|
:handler set-todo-done}
|
||||||
|
|
||||||
|
{:name "delete_todo"
|
||||||
|
:description "Presses a todo's Delete button"
|
||||||
|
:input-schema (object-schema {:id todo-id} ["id"])
|
||||||
|
:handler delete-todo}])
|
||||||
|
|
||||||
|
;; --- Registering them with WebMCP ---
|
||||||
|
|
||||||
|
(defn- text-result [text error?]
|
||||||
|
;; Property names are strings: WebMCP's are not in the Closure externs, so an advanced build
|
||||||
|
;; would rename plain `.-isError` style access.
|
||||||
|
(let [content #js [#js {"type" "text" "text" text}]]
|
||||||
|
(if error?
|
||||||
|
#js {"content" content "isError" true}
|
||||||
|
#js {"content" content})))
|
||||||
|
|
||||||
|
(defn- ->text [value]
|
||||||
|
(cond
|
||||||
|
(string? value) value
|
||||||
|
(nil? value) "ok"
|
||||||
|
:else (js/JSON.stringify (clj->js value))))
|
||||||
|
|
||||||
|
(defn- execute
|
||||||
|
"A WebMCP `execute` callback for `handler`: runs it on the arguments, and turns what it
|
||||||
|
returns, or the error it throws, into a tool result."
|
||||||
|
[handler]
|
||||||
|
(fn [args]
|
||||||
|
(-> (js/Promise.resolve)
|
||||||
|
(.then #(handler (js->clj args :keywordize-keys true)))
|
||||||
|
(.then (fn [value] (text-result (->text value) false)))
|
||||||
|
(.catch (fn [e] (text-result (or (some-> e (aget "message")) (str e)) true))))))
|
||||||
|
|
||||||
|
(defn register-tools!
|
||||||
|
"Registers `tools` on `mcp`, a WebMCP instance: `registerTool(name, description, inputSchema,
|
||||||
|
execute)`. Registering a name again replaces the tool, so this is safe to call again (hot
|
||||||
|
reload). (WebMCP's methods are called by string: they are not in the Closure externs.)"
|
||||||
|
[mcp]
|
||||||
|
(doseq [{:keys [name description input-schema handler]} tools]
|
||||||
|
(js-invoke mcp "registerTool" name description (clj->js input-schema) (execute handler)))
|
||||||
|
mcp)
|
||||||
|
|
||||||
|
(defonce ^:private widget (atom nil))
|
||||||
|
|
||||||
|
(defn register!
|
||||||
|
"Offers `tools` to the agent through WebMCP. The first call creates the WebMCP widget (the small
|
||||||
|
square in the corner of the page, where the user pastes the token from their MCP client), later
|
||||||
|
ones only register the tools again. Returns the WebMCP instance."
|
||||||
|
[]
|
||||||
|
(register-tools! (or @widget (reset! widget (WebMCP.)))))
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
(ns tbd.ui.webmcp-test
|
||||||
|
(:require [cljs.reader :as reader]
|
||||||
|
[cljs.test :refer [deftest is testing async use-fixtures]]
|
||||||
|
[re-frame.core :as rf]
|
||||||
|
[re-frame.db :as rf-db]
|
||||||
|
[tbd.ui.events :as events]
|
||||||
|
[tbd.ui.webmcp :as webmcp]))
|
||||||
|
|
||||||
|
;; A fake browser: the page's server, and the WebMCP registry the tools end up in.
|
||||||
|
|
||||||
|
(def ^:private server
|
||||||
|
"What the fake server holds: {:user nil-or-{:login ..}, :todos [..], :next-id n}."
|
||||||
|
(atom nil))
|
||||||
|
|
||||||
|
(defn- respond [status body]
|
||||||
|
{:status status :body (pr-str body)})
|
||||||
|
|
||||||
|
(defn- route [method uri body]
|
||||||
|
(let [{:keys [user todos]} @server
|
||||||
|
todo-id (some->> (re-find #"^/api/todos/(\d+)$" uri) second js/parseInt)]
|
||||||
|
(cond
|
||||||
|
(= "/auth/me" uri) (if user (respond 200 user) (respond 401 {:error "not logged in"}))
|
||||||
|
(and (= "GET" method) (re-find #"^/api/add\?" uri))
|
||||||
|
(let [[_ x y] (re-find #"x=([^&]*)&y=([^&]*)" uri)]
|
||||||
|
(if-let [error (:add-error @server)]
|
||||||
|
(respond 400 {:error error :details {:x ["is wrong"]}})
|
||||||
|
(respond 200 {:result (+ (js/parseFloat x) (js/parseFloat y))})))
|
||||||
|
(not user) (respond 401 {:error "not logged in"})
|
||||||
|
(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
|
||||||
|
:created-at 1 :updated-at 1}]
|
||||||
|
(swap! server #(-> % (update :todos conj todo) (update :next-id inc)))
|
||||||
|
(respond 201 todo))
|
||||||
|
(respond 400 {:error "Invalid body" :details {:title ["must not be blank"]}}))
|
||||||
|
(and (= "PATCH" method) todo-id)
|
||||||
|
(if-let [todo (first (filter #(= todo-id (:id %)) todos))]
|
||||||
|
(let [changed (merge todo body)]
|
||||||
|
(swap! server update :todos (fn [ts] (mapv #(if (= todo-id (:id %)) changed %) ts)))
|
||||||
|
(respond 200 changed))
|
||||||
|
(respond 404 {:error "todo not found"}))
|
||||||
|
(and (= "DELETE" method) todo-id)
|
||||||
|
(do (swap! server update :todos (fn [ts] (filterv #(not= todo-id (:id %)) ts)))
|
||||||
|
(respond 200 {:deleted true}))
|
||||||
|
:else (respond 404 {:error "not found"}))))
|
||||||
|
|
||||||
|
(defn- fake-fetch [uri opts]
|
||||||
|
(let [opts (js->clj opts :keywordize-keys true)
|
||||||
|
{:keys [status body]} (route (:method opts) uri (some-> (:body opts) reader/read-string))]
|
||||||
|
(js/Promise.resolve
|
||||||
|
#js {:ok (< status 300) :status status :text (fn [] (js/Promise.resolve body))})))
|
||||||
|
|
||||||
|
(def ^:private registered
|
||||||
|
"What the page registered with WebMCP: name -> {:description :input-schema :execute}."
|
||||||
|
(atom {}))
|
||||||
|
|
||||||
|
(def ^:private fake-mcp
|
||||||
|
"A WebMCP instance, as far as the page uses it: registerTool(name, description, schema, execute)."
|
||||||
|
#js {:registerTool (fn [name description input-schema execute]
|
||||||
|
(swap! registered assoc name {:description description
|
||||||
|
:input-schema input-schema
|
||||||
|
:execute execute}))})
|
||||||
|
|
||||||
|
(def ^:private real-fetch (.-fetch js/globalThis))
|
||||||
|
|
||||||
|
(use-fixtures :each
|
||||||
|
{:before (fn []
|
||||||
|
(reset! registered {})
|
||||||
|
(reset! server {:user nil :todos [] :next-id 1})
|
||||||
|
(aset js/globalThis "fetch" fake-fetch))
|
||||||
|
:after (fn []
|
||||||
|
(aset js/globalThis "fetch" real-fetch))})
|
||||||
|
|
||||||
|
(defn- call
|
||||||
|
"A promise of the result of the tool `tool-name` on `args`, as {:text :error?}."
|
||||||
|
[tool-name args]
|
||||||
|
(-> ((:execute (get @registered tool-name)) (clj->js args))
|
||||||
|
(.then (fn [^js result]
|
||||||
|
{:text (.. result -content (at 0) -text)
|
||||||
|
:error? (true? (.-isError result))}))))
|
||||||
|
|
||||||
|
(defn- page-loaded
|
||||||
|
"A promise that is fulfilled once the page has found out who is logged in. A test that ends
|
||||||
|
sooner would leave that answer to arrive in the next test's page."
|
||||||
|
[]
|
||||||
|
(call "whoami" {}))
|
||||||
|
|
||||||
|
(defn- json [text] (js->clj (js/JSON.parse text) :keywordize-keys true))
|
||||||
|
|
||||||
|
(defn- open-page!
|
||||||
|
"Loads the page as core/init does (after the fake server is set up): the app starts up, asking
|
||||||
|
the server who is logged in, and registers its tools."
|
||||||
|
[]
|
||||||
|
(rf/dispatch-sync [::events/initialize])
|
||||||
|
(webmcp/register-tools! fake-mcp))
|
||||||
|
|
||||||
|
(defn- log-in! [] (swap! server assoc :user {:login "alice" :id 7}))
|
||||||
|
|
||||||
|
(deftest registers-the-tools
|
||||||
|
(is (identical? fake-mcp (webmcp/register-tools! fake-mcp)))
|
||||||
|
(is (= (set (map :name webmcp/tools)) (set (keys @registered))))
|
||||||
|
(doseq [[tool-name {:keys [description input-schema execute]}] @registered]
|
||||||
|
(is (and (string? description) (seq description)) tool-name)
|
||||||
|
(is (= "object" (.-type ^js input-schema)) tool-name)
|
||||||
|
(is (object? (.-properties ^js input-schema)) tool-name)
|
||||||
|
(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))))))
|
||||||
|
|
||||||
|
(deftest registering-again-replaces-the-tools
|
||||||
|
(webmcp/register-tools! fake-mcp)
|
||||||
|
(webmcp/register-tools! fake-mcp)
|
||||||
|
(is (= (count webmcp/tools) (count @registered))))
|
||||||
|
|
||||||
|
(deftest ping-and-add
|
||||||
|
(open-page!)
|
||||||
|
(async done
|
||||||
|
(-> (page-loaded)
|
||||||
|
(.then #(call "ping" {}))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= {:text "pong" :error? false} r))
|
||||||
|
(call "add" {:x 2 :y 3})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (false? (:error? r)))
|
||||||
|
(is (= {:x 2 :y 3 :sum 5} (json (:text r))))
|
||||||
|
;; It filled in the form, as a user would.
|
||||||
|
(is (= {:x "2" :y "3" :result {:status :ok :x "2" :y "3" :sum 5}}
|
||||||
|
(:calc @rf-db/app-db)))
|
||||||
|
(call "get_calculator" {})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= "2" (:x (json (:text r)))))
|
||||||
|
(is (= "ok" (:status (:result (json (:text r))))))
|
||||||
|
(call "add" {:x "two" :y 3})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r))
|
||||||
|
(is (re-find #"must be numbers" (:text r)))
|
||||||
|
(done))))))
|
||||||
|
|
||||||
|
(deftest add-shows-the-servers-error
|
||||||
|
(swap! server assoc :add-error "Invalid parameters")
|
||||||
|
(open-page!)
|
||||||
|
(async done
|
||||||
|
(-> (page-loaded)
|
||||||
|
(.then #(call "add" {:x 1 :y 2}))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r))
|
||||||
|
(is (re-find #"Invalid parameters" (:text r)))
|
||||||
|
(call "add" {:x js/NaN :y 1})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r) "not a number, so nothing was sent")
|
||||||
|
(is (re-find #"must be numbers" (:text r)))
|
||||||
|
(done))))))
|
||||||
|
|
||||||
|
(deftest anonymous-user
|
||||||
|
(open-page!)
|
||||||
|
(async done
|
||||||
|
(-> (call "whoami" {})
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= {:text "not logged in" :error? false} r))
|
||||||
|
(call "list_todos" {})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r))
|
||||||
|
(is (re-find #"not logged in" (:text r)))
|
||||||
|
(call "create_todo" {:title "buy milk"})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r))
|
||||||
|
(is (empty? (:todos @server)) "nothing was sent")
|
||||||
|
(done))))))
|
||||||
|
|
||||||
|
(deftest todos
|
||||||
|
(log-in!)
|
||||||
|
(swap! 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
|
||||||
|
(-> (call "whoami" {})
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= "logged in as alice" (:text r)))
|
||||||
|
(call "list_todos" {})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= ["old"] (map :title (json (:text r)))))
|
||||||
|
(call "get_todo" {:id 1})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= "old" (:title (json (:text r)))))
|
||||||
|
(call "get_todo" {:id 99})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= {:text "todo not found" :error? true} r))
|
||||||
|
(call "create_todo" {: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])))
|
||||||
|
;; 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})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (true? (: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! server assoc :todos [])
|
||||||
|
(call "set_todo_done" {:id 2 :done true})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (true? (:done (json (:text r)))))
|
||||||
|
(call "set_todo_done" {:id 2 :done "yes"})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r))
|
||||||
|
(call "delete_todo" {:id 1})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= {:text "deleted" :error? false} r))
|
||||||
|
(is (= ["buy milk"] (map :title (get-in @rf-db/app-db [:todos :items]))))
|
||||||
|
(call "delete_todo" {:id 1})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (= {:text "todo not found" :error? true} r))
|
||||||
|
(call "get_todo" {:id 1.5})))
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r))
|
||||||
|
(done))))))
|
||||||
|
|
||||||
|
(deftest create-todo-shows-the-servers-error
|
||||||
|
(log-in!)
|
||||||
|
(open-page!)
|
||||||
|
(async done
|
||||||
|
(-> (call "create_todo" {:title " "})
|
||||||
|
(.then (fn [r]
|
||||||
|
(is (:error? r))
|
||||||
|
(is (re-find #"Invalid body" (:text r)))
|
||||||
|
(is (empty? (get-in @rf-db/app-db [:todos :items])))
|
||||||
|
(done))))))
|
||||||
Reference in New Issue
Block a user