mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
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
220 lines
10 KiB
Clojure
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)))))
|