penpot/frontend/test/frontend_tests/data/workspace_mcp_test.cljs
Dominik Jain be17fabc6a 🎉 Introduce global plugins and move MCP to application scope
Global plugin lifecycle:
- Add optional manifest scope "global" for plugins that remain active
  across dashboard and workspace navigation within one browser tab.
  Existing plugins retain workspace scope by default.
- Preserve global sandboxes when a workspace ends or another plugin
  loads, including when that other plugin fails to initialize.
- Avoid duplicate instances of an already-running global plugin.
- Close global plugins on explicit closure or logout, with idempotent
  cleanup of listeners, timers and UI resources. Global scope does not
  imply automatic startup or persistence across a browser reload.

Plugin API and navigation:
- Keep a permanently live penpot facade whose getters follow the current
  workspace, while user, theme and UI facilities remain available on
  the dashboard.
- Expose penpotMgmt to global plugins with workspace metadata,
  none/loading/ready status and workspacechange subscriptions.
- Add awaitable openFile(fileId, {teamId}) navigation in the current tab,
  defaulting to the current team and resolving only once the requested
  file and its active page are ready.
- Reject invalid identifiers, failed or superseded navigation and waits
  exceeding 30 seconds; release navigation watchers on completion.
- Guard workspace-bound operations while no workspace is ready and
  return null from root, currentFile and currentPage in that state.
- Require content:read for the initial management API. Callers must
  reacquire file, page and shape references after changing files.

MCP lifecycle and connection controls:
- Make MCP a global plugin and initialize its integrated controller per
  authenticated application session instead of per workspace.
- Load the enabled integration with a valid token on the dashboard as
  well as in a file, retaining its existing hidden-iframe implementation.
- Keep enabling and connecting separate: an enabled plugin starts idle,
  and each tab connects explicitly through dashboard or workspace
  controls.
- Preserve the iframe, WebSocket, heartbeat and reconnect machinery
  during navigation; disconnect on request and close on disable/logout.

MCP sessions and execution:
- Derive the short MCP session ID solely from currentUser.sessionId,
  excluding the file ID. Retain the SHA-256-based, 50-bit Base32 encoding
  to produce a stable, copyable 10-character identifier.
- Allow sessions without an open file and update workspace metadata over
  the existing connection without replacing its routing identity.
- Refresh context after the connection handshake so initialization does
  not leave stale file metadata.
- Expose penpotMgmt to execute_code, preserve execution storage across
  navigation and skip layout waits when no workspace is ready.
- Update API types, tool guidance, documentation and the changelog.

Validation:
- Cover authenticated startup, workspace survival, logout cleanup,
  readiness, navigation failure, duplicate loads and session routing.
- Verify that short IDs remain stable across files and reconnects and
  that disconnect cancels pending asynchronous ID initialization.
- Pass 16 focused frontend tests, 73 runtime tests, 30 MCP plugin tests,
  builds, TypeScript checks and frontend/runtime lint.
- Pass all 73 MCP server tests with --test-force-exit; the normal runner
  still stays alive after assertions finish.
- Verify a live dashboard-to-file-to-dashboard round trip without a
  document or iframe reload, preserving facade identity and storage.

AI-assisted-by: gpt-6
2026-10-07 14:58:32 +02:00

220 lines
10 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.data.workspace-mcp-test
(:require
[app.common.time :as ct]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.plugins :as dp]
[app.main.data.profile :as du]
[app.main.data.workspace.mcp :as mcp]
[app.plugins.register :as preg]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.async :as a]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
(t/deftest ^:async test-initialize-once-per-authenticated-session
(let [stream (rx/subject)
events (atom [])
profile {:id (uuid/next)}]
(await
(mock/with-mocks*
{cf/flags #{:mcp}
preg/wait-for-runtime (fn [] (js/Promise.resolve true))}
(let [subscription (rx/subs! #(swap! events conj (ptk/type %))
(ptk/watch (mcp/initialize) {} stream))]
(try
(rx/push! stream (du/set-profile profile))
(await (a/wait-for #(= 1 (count @events))))
(rx/push! stream (du/set-profile profile))
(rx/push! stream (ptk/data-event :app.main.data.workspace/finalize-workspace))
(await (a/settle))
(t/is (= [:app.main.data.workspace.mcp/init] @events))
(rx/push! stream (ptk/data-event :app.main.data.auth/logged-out))
(rx/push! stream (du/set-profile profile))
(await (a/wait-for #(= 2 (count @events))))
(t/is (= [:app.main.data.workspace.mcp/init
:app.main.data.workspace.mcp/init] @events))
(finally
(rx/dispose! subscription)
(rx/end! stream))))))))
(t/deftest test-update-mcp-status
(t/testing "enables MCP in profile props and mcp state"
(let [state {:profile {:props {:mcp-enabled false}} :mcp {}}
result (ptk/update (mcp/update-mcp-status true) state)]
(t/is (true? (get-in result [:profile :props :mcp-enabled])))
(t/is (true? (get-in result [:mcp :enabled])))))
(t/testing "disables MCP in profile props and mcp state"
(let [state {:profile {:props {:mcp-enabled true}} :mcp {:enabled true}}
result (ptk/update (mcp/update-mcp-status false) state)]
(t/is (false? (get-in result [:profile :props :mcp-enabled])))
(t/is (false? (get-in result [:mcp :enabled]))))))
(t/deftest test-update-mcp-connection-status
(t/testing "sets connection status to connected"
(let [state {:mcp {:connection-requested true :connection-status "disconnected"}}
result (ptk/update (mcp/update-mcp-connection-status "connected") state)]
(t/is (= "connected" (get-in result [:mcp :connection-status])))))
(t/testing "sets connection status to disconnected"
(let [state {:mcp {:connection-requested true :connection-status "connected"}}
result (ptk/update (mcp/update-mcp-connection-status "disconnected") state)]
(t/is (= "disconnected" (get-in result [:mcp :connection-status]))))))
(t/deftest test-init-sets-enabled
(t/testing "init sets :mcp :enabled to true when profile has mcp-enabled"
(let [state {:mcp {} :profile {:props {:mcp-enabled true}}}
result (ptk/update (mcp/init) state)]
(t/is (true? (get-in result [:mcp :enabled])))))
(t/testing "init sets :mcp :enabled to false when profile has mcp-disabled"
(let [state {:mcp {:enabled true} :profile {:props {:mcp-enabled false}}}
result (ptk/update (mcp/init) state)]
(t/is (false? (get-in result [:mcp :enabled])))))
(t/testing "init sets :mcp :enabled to false when profile has no mcp-enabled prop"
(let [state {:mcp {:enabled true} :profile {:props {}}}
result (ptk/update (mcp/init) state)]
(t/is (false? (get-in result [:mcp :enabled]))))))
(t/deftest test-init-mcp-state
(let [token-id (uuid/next)]
(t/testing "with valid MCP token (future expiration)"
(let [future-date (ct/plus (ct/now) #js {:hours 24})
tokens [{:id token-id :type "mcp" :token "abc123" :expires-at future-date}]
event (#'mcp/init-mcp-state tokens)
state {:mcp {}}
result (ptk/update event state)]
(t/is (= "abc123" (get-in result [:mcp :token])))
(t/is (= token-id (get-in result [:mcp :token-id])))
(t/is (true? (get-in result [:mcp :token-valid])))
;; deref should return the token when valid
(t/is (some? @event))))
(t/testing "with MCP token with no expiration"
(let [tokens [{:id token-id :type "mcp" :token "abc123" :expires-at nil}]
event (#'mcp/init-mcp-state tokens)
state {:mcp {}}
result (ptk/update event state)]
(t/is (= "abc123" (get-in result [:mcp :token])))
(t/is (true? (get-in result [:mcp :token-valid])))
(t/is (some? @event))))
(t/testing "with expired MCP token"
(let [past-date (ct/minus (ct/now) #js {:hours 24})
tokens [{:id token-id :type "mcp" :token "abc123" :expires-at past-date}]
event (#'mcp/init-mcp-state tokens)
state {:mcp {}}
result (ptk/update event state)]
(t/is (= "abc123" (get-in result [:mcp :token])))
(t/is (false? (get-in result [:mcp :token-valid])))
;; deref should return nil when token is expired
(t/is (nil? @event))))
(t/testing "with no MCP token"
(let [tokens [{:id token-id :type nil :token "regular-token"}]
event (#'mcp/init-mcp-state tokens)
state {:mcp {:existing "data"}}
result (ptk/update event state)]
;; state should be unchanged when no MCP token exists
(t/is (= {:mcp {:existing "data"}} result))
(t/is (nil? @event))))
(t/testing "with mixed tokens finds MCP token"
(let [regular-id (uuid/next)
mcp-id (uuid/next)
tokens [{:id regular-id :type nil :token "regular"}
{:id mcp-id :type "mcp" :token "mcp-token" :expires-at nil}]
event (#'mcp/init-mcp-state tokens)
result (ptk/update event {:mcp {}})]
(t/is (= "mcp-token" (get-in result [:mcp :token])))
(t/is (= mcp-id (get-in result [:mcp :token-id])))))))
(t/deftest test-delete-access-token-optimistic-update
(let [token-1 {:id (uuid/next) :name "token-1"}
token-2 {:id (uuid/next) :name "token-2"}
token-3 {:id (uuid/next) :name "token-3"}]
(t/testing "removes token from :access-tokens optimistically"
(let [state {:access-tokens [token-1 token-2 token-3]}
event (du/delete-access-token {:id (:id token-2)})
result (ptk/update event state)]
(t/is (= 2 (count (:access-tokens result))))
(t/is (= [token-1 token-3] (:access-tokens result)))))
(t/testing "state unchanged when token id not found"
(let [state {:access-tokens [token-1 token-2]}
event (du/delete-access-token {:id (uuid/next)})
result (ptk/update event state)]
(t/is (= 2 (count (:access-tokens result))))))))
(t/deftest ^:async test-enable-does-not-connect
(let [events (atom [])]
(await (a/observe (ptk/watch (mcp/update-mcp-status true) {} (rx/empty))
:on-next #(swap! events conj %)))
(t/is (empty? @events))))
(t/deftest ^:async test-connect-is-local
(let [event (mcp/connect-mcp)
state (ptk/update event {:mcp {:enabled true :token-valid true}})
events (atom [])]
(t/is (true? (get-in state [:mcp :connection-requested])))
(await (a/observe (ptk/watch event state (rx/empty))
:on-next #(swap! events conj (ptk/type %))))
(t/is (= [:app.main.data.workspace.mcp/connect] @events))))
(t/deftest test-disconnect-clears-connection-intent
(let [state {:mcp {:connection-requested true
:connection-status "connecting"
:session-id "pq3gxqddgj"}}
result (ptk/update (mcp/user-disconnect-mcp) state)]
(t/is (false? (get-in result [:mcp :connection-requested])))
(t/is (= "disconnected" (get-in result [:mcp :connection-status])))
(t/is (nil? (get-in result [:mcp :session-id])))))
(t/deftest test-init-clears-previous-app-connection
(let [state {:profile {:props {:mcp-enabled true}}
:mcp {:connection-requested true
:connection-status "connected"
:session-id "pq3gxqddgj"}}
result (ptk/update (mcp/init) state)]
(t/is (false? (get-in result [:mcp :connection-requested])))
(t/is (= "disconnected" (get-in result [:mcp :connection-status])))
(t/is (nil? (get-in result [:mcp :session-id])))))
(t/deftest test-late-status-cannot-reconnect
(let [state {:mcp {:connection-requested false :connection-status "disconnected"}}]
(t/is (= state (ptk/update (mcp/update-mcp-connection-status "connected") state)))))
(t/deftest test-plugin-callbacks-stop-with-app-session
(doseq [stop-event [:app.main.data.auth/logged-out
:app.main.data.workspace.mcp/init]]
(let [stream (rx/subject)
extension (atom nil)
calls (atom 0)
closed (atom 0)]
(with-redefs [dp/start-plugin! (fn [_ extensions]
(reset! extension (.-mcp extensions)))
dp/close-plugin! (fn [_] (swap! closed inc))]
(ptk/effect (#'mcp/init-mcp-plugin {:token "test-token"}) nil stream)
(.on @extension "connect" #(swap! calls inc))
(rx/push! stream (ptk/data-event :app.main.data.workspace.mcp/connect))
(t/is (= 1 @calls))
(rx/push! stream (ptk/data-event :app.main.data.workspace/finalize-workspace))
(t/is (zero? @closed))
(rx/push! stream (ptk/data-event stop-event))
(t/is (= 1 @closed))
(t/is (false? (.isConnectionRequested @extension)))
(.on @extension "connect" #(swap! calls inc))
(rx/push! stream (ptk/data-event :app.main.data.workspace.mcp/connect))
(t/is (= 1 @calls))
(rx/end! stream)))))