mirror of
https://github.com/penpot/penpot.git
synced 2026-11-05 17:40:39 +00:00
* 🐛 Fail MCP tasks at once when their plugin connection closes A response only counts when it comes from the connection the task went to, so a task sent over a connection that then closes can never finish. It used to wait for its full timeout. The plugin bridge now fails such tasks as soon as their connection closes. Connections also log their close code and reason, to tell a page closing its socket from a browser or network failure. * ✨ Announce the MCP session to a browser extension Let a browser extension learn which tab holds an MCP session, so it can keep the browser from discarding that tab. While the MCP plugin runs, the page listens for the extension's hello message, and says it is ready once it listens, so that a content script that said hello too early says it again. After a hello, the page posts its session ID and connection status, and posts them again on every status change. When the plugin stops, it reports the session as disconnected. The plugin also reports "disconnected" while it reconnects on its own, for instance after a page resume, so the page announces "connecting" then and "disconnected" only once the user has stopped the connection. Otherwise the extension would forget the session at every reconnect. The page only accepts messages posted on its own window by its own origin, posts to its own origin only, and sends nothing before the extension asks. It reaches the window through app.util.globals, whose stand-in for the unit tests accepts postMessage. * ✨ Keep Penpot tabs connected to MCP from being frozen Chrome freezes some background tabs, for instance busy ones while Battery Saver is on, and a frozen page cannot run the MCP plugin. Chrome does not freeze a page that holds a Web Lock, so the page holds a lock named penpot-mcp-session from the moment the user asks to connect until the connection stops: on disconnect, logout, MCP being turned off or the MCP runtime starting again. The lock is shared, so several tabs can hold it at once. Browsers without Web Locks skip it, and when the lock request fails, the next connection request tries again. Firefox does not freeze background pages, so the lock changes nothing there. * ✨ Add a browser extension that keeps MCP tabs from being discarded A browser may discard a background tab to save memory. A discarded Penpot tab reloads when the user returns to it and loses its MCP session. Add mcp/packages/browser-extension, a Manifest V3 extension for Chrome and Firefox, built from one source into dist/chrome and dist/firefox. It is written in TypeScript, bundled with esbuild and imports the protocol types from mcp-common. The package joins the mcp workspace, so the build, test and fmt scripts of mcp/ cover it; the server release build skips it. Its content script runs on design.penpot.app, on the test environments under penpot.dev and on local development instances. It asks the page for its MCP session on load, again when the page says it is ready and when the page returns from the back/forward cache, and forwards each valid session announcement to the background. It accepts only messages the page posted on its own window from its own origin, and posts its hello to that origin only. It also tells the background when the page is hidden. The background records which tab holds each MCP session. A tab holds the session it last announced as connected, until its page announces a disconnect, is hidden, or the tab is closed or replaced. The map lives in chrome.storage.session, so it survives restarts of the background and clears when the browser closes. While a tab holds a session, the browser may not discard it automatically. The extension asks only for the storage permission. Chrome runs the background as a service worker; Firefox runs it as an event page and needs the add-on ID, mcp-extension@penpot.app, and Firefox 140. The toolbar popup lists the connected tabs. The icon is the Penpot logo, white on a black rounded square, with the SVG source next to the PNGs. A README explains how the extension works and how to build and load it. * 🐛 Ask MCP plugins for heartbeats so hidden tabs do not look frozen The server refuses to send a task to a plugin whose last heartbeat is older than 30 s, taking its tab for frozen. The plugin sent heartbeats only from a 10 s timer, and Chrome runs the timers of a hidden page as rarely as once a minute: without Energy Saver after about a minute hidden, and at once with it. A Penpot tab hidden for a minute therefore failed that check about half the time, although it could run tasks. Chrome handles socket messages of a hidden page at once, and a frozen page cannot handle them at all. The server now sends each plugin a heartbeat request every 10 s, and the plugin answers it with a heartbeat, so the heartbeat age again tells a frozen tab from a hidden one. The plugin keeps its timer, so it still sends heartbeats to a server that does not ask. A plugin that does not know the request passes it to the plugin code, which ignores it.
394 lines
15 KiB
TypeScript
394 lines
15 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { randomUUID } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { runInNewContext } from "node:vm";
|
|
import ts from "typescript";
|
|
import { shouldReconnectAfterClose } from "./ReconnectPolicy.ts";
|
|
import { SessionId } from "./SessionId.ts";
|
|
import { SessionIdDisplay } from "./SessionIdDisplay.ts";
|
|
|
|
/** Compiles the actual plugin entry point for a simulated browser boundary. */
|
|
function compile(name: string) {
|
|
return ts.transpileModule(readFileSync(new URL(name, import.meta.url), "utf8"), {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
|
}).outputText;
|
|
}
|
|
|
|
function pluginUi() {
|
|
const messages: any[] = [];
|
|
const sockets: FakeSocket[] = [];
|
|
const events = new Map<string, (event?: any) => void>();
|
|
const timers = new Map<number, () => void>();
|
|
let timerId = 0;
|
|
class FakeSocket {
|
|
static OPEN = 1;
|
|
static CONNECTING = 0;
|
|
readyState = 0;
|
|
sent: any[] = [];
|
|
onopen?: () => void;
|
|
onclose?: (event: any) => void;
|
|
onmessage?: (event: any) => void;
|
|
constructor(_url: string) {
|
|
sockets.push(this);
|
|
}
|
|
open() {
|
|
this.readyState = 1;
|
|
this.onopen?.();
|
|
}
|
|
send(raw: string) {
|
|
this.sent.push(JSON.parse(raw));
|
|
}
|
|
close() {
|
|
this.readyState = 3;
|
|
this.onclose?.({ code: 1000 });
|
|
}
|
|
receive(message: any) {
|
|
this.onmessage?.({ data: JSON.stringify(message) });
|
|
}
|
|
}
|
|
const addEventListener = (name: string, handler: (event?: any) => void) => events.set(name, handler);
|
|
runInNewContext(compile("main.ts"), {
|
|
exports: {},
|
|
require: (name: string) => {
|
|
if (name === "./style.css") return {};
|
|
if (name === "./ReconnectPolicy") return { shouldReconnectAfterClose };
|
|
if (name === "./SessionId") return { SessionId };
|
|
if (name === "./SessionIdDisplay") return { SessionIdDisplay };
|
|
throw new Error(`Unexpected import: ${name}`);
|
|
},
|
|
console: { log() {}, error() {}, warn() {} },
|
|
crypto: { randomUUID },
|
|
URLSearchParams,
|
|
WebSocket: FakeSocket,
|
|
window: { location: { hash: "" }, addEventListener },
|
|
document: { body: { dataset: {} }, getElementById: () => null, addEventListener },
|
|
parent: { postMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))) },
|
|
setTimeout: (callback: () => void) => {
|
|
timers.set(++timerId, callback);
|
|
return timerId;
|
|
},
|
|
clearTimeout: (id: number) => timers.delete(id),
|
|
setInterval: () => ++timerId,
|
|
clearInterval: () => {},
|
|
PENPOT_MCP_WEBSOCKET_URL: "ws://localhost:4402",
|
|
});
|
|
const message = (data: any) => events.get("message")!({ data });
|
|
const connect = () => {
|
|
message({ type: "start-server" });
|
|
sockets.at(-1)!.open();
|
|
return messages.filter((message) => message.type === "connection-metadata-request").at(-1)!.sessionId;
|
|
};
|
|
return { messages, sockets, events, timers, message, connect };
|
|
}
|
|
|
|
test("sends file metadata before marking a plugin connection ready", async () => {
|
|
const ui = pluginUi();
|
|
const sessionId = ui.connect();
|
|
const initialization = {
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId, fileId: "file-1", fileName: "Design" },
|
|
};
|
|
await ui.message(initialization);
|
|
const registeredSession = ui.sockets[0].sent[0].session;
|
|
assert.equal(registeredSession.sessionId, "ftdmhltgwk");
|
|
assert.notEqual(registeredSession.sessionId, sessionId);
|
|
assert.deepEqual(registeredSession, { ...initialization.session, sessionId: registeredSession.sessionId });
|
|
assert.equal(
|
|
ui.messages.some((message) => message.status === "connected"),
|
|
false
|
|
);
|
|
ui.sockets[0].receive({ type: "initialized" });
|
|
assert.equal(
|
|
ui.messages.filter((message) => message.type === "update-connection-status").at(-1).status,
|
|
"connected"
|
|
);
|
|
});
|
|
|
|
test("reconnects with the same session ID and ignores metadata for the old socket", async () => {
|
|
const ui = pluginUi();
|
|
const firstId = ui.connect();
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: firstId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
const sessionId = ui.sockets[0].sent[0].session.sessionId;
|
|
ui.sockets[0].close();
|
|
const reconnect = [...ui.timers.values()][0];
|
|
reconnect();
|
|
ui.sockets[1].open();
|
|
const secondId = ui.messages.filter((message) => message.type === "connection-metadata-request").at(-1).sessionId;
|
|
assert.notEqual(firstId, secondId);
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: firstId, fileId: "old", fileName: "Old file" },
|
|
});
|
|
assert.deepEqual(ui.sockets[1].sent, []);
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: secondId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
assert.equal(ui.sockets[1].sent[0].session.sessionId, sessionId);
|
|
assert.notEqual(ui.sockets[1].sent[0].session.sessionId, secondId);
|
|
});
|
|
|
|
test("resume and freeze events cannot send liveness messages before initialization", () => {
|
|
const ui = pluginUi();
|
|
ui.connect();
|
|
ui.events.get("resume")!();
|
|
ui.events.get("freeze")!();
|
|
assert.deepEqual(ui.sockets[0].sent, []);
|
|
ui.sockets[0].receive({ type: "initialized" });
|
|
ui.events.get("resume")!();
|
|
assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]);
|
|
});
|
|
|
|
test("answers heartbeat requests once initialized, without forwarding them to the plugin", () => {
|
|
const ui = pluginUi();
|
|
ui.connect();
|
|
ui.sockets[0].receive({ type: "heartbeat-request" });
|
|
assert.deepEqual(ui.sockets[0].sent, []);
|
|
ui.sockets[0].receive({ type: "initialized" });
|
|
ui.sockets[0].receive({ type: "heartbeat-request" });
|
|
assert.deepEqual(ui.sockets[0].sent, [{ type: "heartbeat" }]);
|
|
assert.equal(ui.messages.filter((message) => message.type === "heartbeat-request").length, 0);
|
|
});
|
|
|
|
test("plugin reads the current file for each connection metadata request", () => {
|
|
let onMessage!: (message: any) => void;
|
|
const messages: any[] = [];
|
|
const penpot = {
|
|
currentFile: { id: "file-1", name: "First design" },
|
|
currentUser: { sessionId: "tab-1" },
|
|
theme: "dark",
|
|
ui: {
|
|
open() {},
|
|
onMessage: (handler: (message: any) => void) => {
|
|
onMessage = handler;
|
|
},
|
|
sendMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))),
|
|
},
|
|
on() {},
|
|
};
|
|
runInNewContext(compile("plugin.ts"), {
|
|
exports: {},
|
|
penpot,
|
|
penpotMgmt: {
|
|
get workspace() {
|
|
return { status: "ready", fileId: penpot.currentFile.id, fileName: penpot.currentFile.name };
|
|
},
|
|
on() {},
|
|
},
|
|
mcp: undefined,
|
|
require: () => ({ ExecuteCodeTaskHandler: class {} }),
|
|
});
|
|
onMessage({ type: "connection-metadata-request", sessionId: "first" });
|
|
penpot.currentFile = { id: "file-2", name: "Second design" };
|
|
onMessage({ type: "connection-metadata-request", sessionId: "second" });
|
|
assert.deepEqual(messages, [
|
|
{
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: "first", fileId: "file-1", fileName: "First design", workspaceState: "ready" },
|
|
},
|
|
{
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: "second", fileId: "file-2", fileName: "Second design", workspaceState: "ready" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("plugin without penpotMgmt derives metadata from the open file", () => {
|
|
let onMessage!: (message: any) => void;
|
|
const messages: any[] = [];
|
|
const penpot = {
|
|
currentFile: { id: "file-1", name: "Design" } as { id: string; name: string } | null,
|
|
currentUser: { sessionId: "tab-1" },
|
|
theme: "dark",
|
|
ui: {
|
|
open() {},
|
|
onMessage: (handler: (message: any) => void) => {
|
|
onMessage = handler;
|
|
},
|
|
sendMessage: (message: any) => messages.push(JSON.parse(JSON.stringify(message))),
|
|
},
|
|
on() {},
|
|
};
|
|
runInNewContext(compile("plugin.ts"), {
|
|
exports: {},
|
|
penpot,
|
|
mcp: undefined,
|
|
require: () => ({ ExecuteCodeTaskHandler: class {} }),
|
|
});
|
|
onMessage({ type: "connection-metadata-request", sessionId: "first" });
|
|
penpot.currentFile = null;
|
|
onMessage({ type: "context-request" });
|
|
assert.deepEqual(messages, [
|
|
{
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: "first", fileId: "file-1", fileName: "Design", workspaceState: "ready" },
|
|
},
|
|
{
|
|
type: "context-update",
|
|
context: { fileId: null, fileName: null, workspaceState: "none" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("integrated connections reuse the short session ID across reconnects", async () => {
|
|
const ui = pluginUi();
|
|
ui.message({ type: "mcp-mode", integratedRemoteMcp: true });
|
|
const initialize = async (requestId: string) => {
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: requestId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
};
|
|
await initialize(ui.connect());
|
|
assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk");
|
|
ui.sockets[0].receive({ type: "initialized" });
|
|
assert.equal(
|
|
ui.messages.filter((message) => message.type === "update-connection-status").at(-1).sessionId,
|
|
"ftdmhltgwk"
|
|
);
|
|
ui.sockets[0].close();
|
|
[...ui.timers.values()][0]();
|
|
ui.sockets[1].open();
|
|
const requestId = ui.messages.filter((message) => message.type === "connection-metadata-request").at(-1).sessionId;
|
|
await initialize(requestId);
|
|
assert.equal(ui.sockets[1].sent[0].session.sessionId, "ftdmhltgwk");
|
|
});
|
|
|
|
test("disconnect cancels metadata initialization and ignores a late acknowledgement", async () => {
|
|
const ui = pluginUi();
|
|
ui.message({ type: "mcp-mode", integratedRemoteMcp: true });
|
|
const requestId = ui.connect();
|
|
const pending = ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: requestId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
ui.message({ type: "stop-server" });
|
|
await pending;
|
|
assert.deepEqual(ui.sockets[0].sent, []);
|
|
ui.sockets[0].receive({ type: "initialized" });
|
|
assert.ok(!ui.messages.some((message) => message.status === "connected"));
|
|
assert.equal(ui.messages.at(-1).status, "disconnected");
|
|
assert.equal(ui.timers.size, 0);
|
|
});
|
|
|
|
test("integrated plugin stays idle until explicitly connected, including while the UI loads", () => {
|
|
let onMessage!: (message: any) => void;
|
|
const events = new Map<string, () => void>();
|
|
const messages: any[] = [];
|
|
const statuses: string[] = [];
|
|
runInNewContext(compile("plugin.ts"), {
|
|
exports: {},
|
|
console: { log() {} },
|
|
PENPOT_MCP_VERSION: "0.0.0",
|
|
penpot: {
|
|
theme: "dark",
|
|
version: "0.0.0",
|
|
ui: {
|
|
open() {},
|
|
onMessage: (handler: (message: any) => void) => {
|
|
onMessage = handler;
|
|
},
|
|
sendMessage: (message: any) => messages.push(message),
|
|
},
|
|
on() {},
|
|
},
|
|
penpotMgmt: { on() {} },
|
|
mcp: {
|
|
getToken: () => "test-token",
|
|
getServerUrl: () => "ws://localhost:4402",
|
|
isConnectionRequested: () => false,
|
|
setMcpStatus: (status: string) => statuses.push(status),
|
|
on: (event: string, callback: () => void) => events.set(event, callback),
|
|
},
|
|
require: () => ({ ExecuteCodeTaskHandler: class {} }),
|
|
});
|
|
assert.ok(!statuses.includes("connecting"));
|
|
events.get("connect")!();
|
|
events.get("disconnect")!();
|
|
onMessage({ type: "ui-initialized" });
|
|
assert.ok(!messages.some((message) => message.type === "start-server"));
|
|
events.get("connect")!();
|
|
assert.equal(messages.at(-1).type, "start-server");
|
|
});
|
|
|
|
test("standalone session ID survives reconnects and plugin restarts in the same app instance", async () => {
|
|
const ui = pluginUi();
|
|
const firstRequestId = ui.connect();
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: firstRequestId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
const sessionId = ui.sockets[0].sent[0].session.sessionId;
|
|
ui.message({ type: "stop-server" });
|
|
assert.equal(ui.messages.at(-1).sessionId, null);
|
|
const secondRequestId = ui.connect();
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: secondRequestId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
assert.equal(ui.sockets[1].sent[0].session.sessionId, sessionId);
|
|
ui.sockets[1].receive({ type: "initialized" });
|
|
assert.equal(
|
|
ui.messages.filter((message) => message.type === "update-connection-status").at(-1).sessionId,
|
|
sessionId
|
|
);
|
|
|
|
const next = pluginUi();
|
|
const nextRequestId = next.connect();
|
|
await next.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: nextRequestId, fileId: "file-1", fileName: "Design" },
|
|
});
|
|
assert.equal(next.sockets[0].sent[0].session.sessionId, sessionId);
|
|
});
|
|
|
|
test("file metadata does not affect the short session ID", async () => {
|
|
for (const fileId of [null, "file-1", "file-2"]) {
|
|
const ui = pluginUi();
|
|
const requestId = ui.connect();
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: requestId, fileId, fileName: fileId },
|
|
});
|
|
assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk");
|
|
}
|
|
});
|
|
|
|
test("dashboard sessions use the short session ID and publish context changes without reconnecting", async () => {
|
|
const ui = pluginUi();
|
|
const requestId = ui.connect();
|
|
await ui.message({
|
|
type: "initialize",
|
|
penpotUserSessionId: "tab-1",
|
|
session: { sessionId: requestId, fileId: null, fileName: null, workspaceState: "none" },
|
|
});
|
|
assert.equal(ui.sockets[0].sent[0].session.sessionId, "ftdmhltgwk");
|
|
ui.sockets[0].receive({ type: "initialized" });
|
|
assert.equal(ui.messages.at(-1).type, "context-request");
|
|
for (const context of [
|
|
{ fileId: "file-1", fileName: "Design", workspaceState: "ready" },
|
|
{ fileId: null, fileName: null, workspaceState: "none" },
|
|
]) {
|
|
await ui.message({ type: "context-update", context });
|
|
assert.deepEqual(ui.sockets[0].sent.at(-1), { type: "context-update", context });
|
|
}
|
|
assert.equal(ui.sockets.length, 1);
|
|
});
|