drawio-mcp-server 2.0.3 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { join } from "node:path";
|
|
|
11
11
|
import { fileURLToPath } from "node:url";
|
|
12
12
|
import { readFileSync, existsSync, statSync, readdirSync, realpathSync, } from "node:fs";
|
|
13
13
|
import { WebSocket, WebSocketServer } from "ws";
|
|
14
|
-
const VERSION = process.env.npm_package_version ?? "2.0.
|
|
14
|
+
const VERSION = process.env.npm_package_version ?? "2.0.4";
|
|
15
15
|
import { buildConfig, shouldShowHelp, getHttpFeatureConfig, } from "./config.js";
|
|
16
16
|
import { bus_reply_stream, bus_request_stream, } from "./types.js";
|
|
17
17
|
import { create_bus } from "./emitter_bus.js";
|
|
@@ -72,10 +72,12 @@ if (logger_type === "mcp_server") {
|
|
|
72
72
|
},
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
|
-
async function start_stdio_transport(
|
|
75
|
+
async function start_stdio_transport(createServer, log) {
|
|
76
|
+
const server = createServer();
|
|
76
77
|
const transport = new StdioServerTransport();
|
|
77
78
|
await server.connect(transport);
|
|
78
79
|
log.debug(`Draw.io MCP Server STDIO transport active`);
|
|
80
|
+
return server;
|
|
79
81
|
}
|
|
80
82
|
function setupCors(app) {
|
|
81
83
|
app.use("*", cors({
|
|
@@ -175,28 +177,35 @@ function registerEditorRoutes(app, config, log) {
|
|
|
175
177
|
});
|
|
176
178
|
log.debug(`Draw.io editor enabled at: http://localhost:${config.httpPort}/`);
|
|
177
179
|
}
|
|
178
|
-
function registerMcpRoute(app) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
180
|
+
function registerMcpRoute(app, createServer, disposeMcpServer) {
|
|
181
|
+
app.all("/mcp", async (c) => {
|
|
182
|
+
const transport = new WebStandardStreamableHTTPServerTransport();
|
|
183
|
+
const server = createServer();
|
|
184
|
+
await server.connect(transport);
|
|
185
|
+
// Remove the server from the tracking set immediately so it does not
|
|
186
|
+
// prevent garbage collection. We intentionally do *not* call
|
|
187
|
+
// server.close() here because the response may be a long-lived SSE
|
|
188
|
+
// stream (e.g. for GET requests). The transport / server will be
|
|
189
|
+
// collected once the response is fully consumed.
|
|
190
|
+
disposeMcpServer(server);
|
|
191
|
+
return transport.handleRequest(c.req.raw);
|
|
192
|
+
});
|
|
182
193
|
}
|
|
183
|
-
function createHttpApp(log, config, features) {
|
|
194
|
+
function createHttpApp(log, config, features, createServer, disposeMcpServer) {
|
|
184
195
|
const app = new Hono();
|
|
185
196
|
setupCors(app);
|
|
186
197
|
if (features.enableHealth)
|
|
187
198
|
registerHealthRoute(app);
|
|
188
199
|
if (features.enableConfig)
|
|
189
200
|
registerConfigRoute(app, config);
|
|
190
|
-
|
|
201
|
+
if (features.enableMcp)
|
|
202
|
+
registerMcpRoute(app, createServer, disposeMcpServer);
|
|
191
203
|
if (features.enableEditor)
|
|
192
204
|
registerEditorRoutes(app, config, log);
|
|
193
|
-
return { app
|
|
205
|
+
return { app };
|
|
194
206
|
}
|
|
195
|
-
async function startHttpServer(
|
|
196
|
-
const { app
|
|
197
|
-
if (mcpTransport) {
|
|
198
|
-
await server.connect(mcpTransport);
|
|
199
|
-
}
|
|
207
|
+
async function startHttpServer(createServer, disposeMcpServer, log, httpPort, config, features) {
|
|
208
|
+
const { app } = createHttpApp(log, config, features, createServer, disposeMcpServer);
|
|
200
209
|
const httpServer = serve({
|
|
201
210
|
fetch: app.fetch,
|
|
202
211
|
port: httpPort,
|
|
@@ -217,28 +226,68 @@ async function startHttpServer(server, log, httpPort, config, features) {
|
|
|
217
226
|
};
|
|
218
227
|
}
|
|
219
228
|
export function createDrawioMcpApp(overrides) {
|
|
220
|
-
const server = new McpServer({
|
|
221
|
-
name: "drawio-mcp-server",
|
|
222
|
-
version: VERSION,
|
|
223
|
-
}, {
|
|
224
|
-
capabilities,
|
|
225
|
-
});
|
|
226
|
-
const log = overrides?.log ??
|
|
227
|
-
(logger_type === "mcp_server"
|
|
228
|
-
? create_server_logger(server)
|
|
229
|
-
: create_console_logger());
|
|
230
229
|
const emitter = new EventEmitter();
|
|
231
230
|
const conns = new Set();
|
|
232
|
-
const
|
|
231
|
+
const mcpServers = new Set();
|
|
232
|
+
// Lazily resolved log — uses console logger until a server logger is
|
|
233
|
+
// explicitly requested via LOGGER_TYPE=mcp_server, in which case the
|
|
234
|
+
// first McpServer created will be used for the logger binding.
|
|
235
|
+
let _log = overrides?.log;
|
|
236
|
+
let _serverLoggerBound = false;
|
|
237
|
+
function getLog() {
|
|
238
|
+
if (_log)
|
|
239
|
+
return _log;
|
|
240
|
+
_log = create_console_logger();
|
|
241
|
+
return _log;
|
|
242
|
+
}
|
|
243
|
+
// Proxy logger that lazily resolves to the real logger, allowing the
|
|
244
|
+
// mcp_server_logger to be bound after the first McpServer is created.
|
|
245
|
+
const lazyLog = {
|
|
246
|
+
log: (level, message, ...data) => getLog().log(level, message, ...data),
|
|
247
|
+
debug: (message, ...data) => getLog().debug(message, ...data),
|
|
248
|
+
};
|
|
249
|
+
const bus = create_bus(lazyLog)(emitter);
|
|
233
250
|
const id_generator = nanoid_id_generator();
|
|
234
251
|
const context = {
|
|
235
252
|
bus,
|
|
236
253
|
id_generator,
|
|
237
|
-
log
|
|
254
|
+
get log() {
|
|
255
|
+
return getLog();
|
|
256
|
+
},
|
|
238
257
|
};
|
|
239
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Factory: creates a new McpServer instance with all tools registered.
|
|
260
|
+
* Each transport must use its own McpServer since the MCP SDK only
|
|
261
|
+
* allows a single transport connection per Protocol instance.
|
|
262
|
+
*/
|
|
263
|
+
function createMcpServer() {
|
|
264
|
+
const server = new McpServer({
|
|
265
|
+
name: "drawio-mcp-server",
|
|
266
|
+
version: VERSION,
|
|
267
|
+
}, {
|
|
268
|
+
capabilities,
|
|
269
|
+
});
|
|
270
|
+
// Bind the mcp_server logger to the first server created (if requested)
|
|
271
|
+
if (logger_type === "mcp_server" &&
|
|
272
|
+
!_serverLoggerBound &&
|
|
273
|
+
!overrides?.log) {
|
|
274
|
+
_log = create_server_logger(server);
|
|
275
|
+
_serverLoggerBound = true;
|
|
276
|
+
}
|
|
277
|
+
registerTools(server, context);
|
|
278
|
+
mcpServers.add(server);
|
|
279
|
+
return server;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Remove a previously created McpServer from the tracking set.
|
|
283
|
+
* Used by the HTTP route handler to prevent unbounded growth of
|
|
284
|
+
* the set when creating per-request servers in stateless mode.
|
|
285
|
+
*/
|
|
286
|
+
function disposeMcpServer(server) {
|
|
287
|
+
mcpServers.delete(server);
|
|
288
|
+
}
|
|
240
289
|
const bus_to_ws_forwarder_listener = (event) => {
|
|
241
|
-
|
|
290
|
+
getLog().debug(`[bridge] received; forwarding message to #${conns.size} clients`, event);
|
|
242
291
|
for (const ws of [...conns]) {
|
|
243
292
|
if (ws.readyState !== WebSocket.OPEN) {
|
|
244
293
|
conns.delete(ws);
|
|
@@ -248,7 +297,7 @@ export function createDrawioMcpApp(overrides) {
|
|
|
248
297
|
ws.send(JSON.stringify(event));
|
|
249
298
|
}
|
|
250
299
|
catch (e) {
|
|
251
|
-
|
|
300
|
+
getLog().debug("[bridge] error forwarding request", e);
|
|
252
301
|
conns.delete(ws);
|
|
253
302
|
}
|
|
254
303
|
}
|
|
@@ -257,7 +306,7 @@ export function createDrawioMcpApp(overrides) {
|
|
|
257
306
|
let wsServer;
|
|
258
307
|
let httpServer;
|
|
259
308
|
async function startWebSocketServer(extensionPort = 3333) {
|
|
260
|
-
|
|
309
|
+
getLog().debug(`Draw.io MCP Server (${VERSION}) starting (WebSocket extension port: ${extensionPort})`);
|
|
261
310
|
if (extensionPort !== 0) {
|
|
262
311
|
const isPortAvailable = await checkPortAvailable(extensionPort);
|
|
263
312
|
if (!isPortAvailable) {
|
|
@@ -266,25 +315,25 @@ export function createDrawioMcpApp(overrides) {
|
|
|
266
315
|
}
|
|
267
316
|
wsServer = new WebSocketServer({ port: extensionPort });
|
|
268
317
|
wsServer.on("connection", (ws) => {
|
|
269
|
-
|
|
318
|
+
getLog().debug(`[ws_handler] A WebSocket client #${conns.size} connected, presumably MCP Extension!`);
|
|
270
319
|
conns.add(ws);
|
|
271
320
|
ws.on("message", (data) => {
|
|
272
321
|
const str = typeof data === "string" ? data : data.toString();
|
|
273
322
|
try {
|
|
274
323
|
const json = JSON.parse(str);
|
|
275
|
-
|
|
324
|
+
getLog().debug(`[ws] received from Extension`, json);
|
|
276
325
|
emitter.emit(bus_reply_stream, json);
|
|
277
326
|
}
|
|
278
327
|
catch (error) {
|
|
279
|
-
|
|
328
|
+
getLog().debug(`[ws] failed to parse message`, error);
|
|
280
329
|
}
|
|
281
330
|
});
|
|
282
331
|
ws.on("close", (code) => {
|
|
283
332
|
conns.delete(ws);
|
|
284
|
-
|
|
333
|
+
getLog().debug(`[ws_handler] WebSocket client closed with code ${code}`);
|
|
285
334
|
});
|
|
286
335
|
ws.on("error", (error) => {
|
|
287
|
-
|
|
336
|
+
getLog().debug(`[ws_handler] WebSocket client error`, error);
|
|
288
337
|
conns.delete(ws);
|
|
289
338
|
});
|
|
290
339
|
});
|
|
@@ -292,7 +341,7 @@ export function createDrawioMcpApp(overrides) {
|
|
|
292
341
|
const onListening = () => {
|
|
293
342
|
wsServer?.off("error", onError);
|
|
294
343
|
const address = wsServer?.address();
|
|
295
|
-
|
|
344
|
+
getLog().debug(`[start_websocket_server] Listening to port ${address?.port ?? extensionPort}`);
|
|
296
345
|
resolve();
|
|
297
346
|
};
|
|
298
347
|
const onError = (error) => {
|
|
@@ -315,7 +364,10 @@ export function createDrawioMcpApp(overrides) {
|
|
|
315
364
|
}
|
|
316
365
|
}
|
|
317
366
|
conns.clear();
|
|
318
|
-
|
|
367
|
+
for (const s of mcpServers) {
|
|
368
|
+
await s.close();
|
|
369
|
+
}
|
|
370
|
+
mcpServers.clear();
|
|
319
371
|
if (wsServer) {
|
|
320
372
|
await new Promise((resolve, reject) => {
|
|
321
373
|
wsServer?.close((error) => {
|
|
@@ -342,15 +394,19 @@ export function createDrawioMcpApp(overrides) {
|
|
|
342
394
|
}
|
|
343
395
|
}
|
|
344
396
|
return {
|
|
345
|
-
|
|
346
|
-
log
|
|
397
|
+
createMcpServer,
|
|
398
|
+
get log() {
|
|
399
|
+
return getLog();
|
|
400
|
+
},
|
|
347
401
|
context,
|
|
348
402
|
emitter,
|
|
349
403
|
close,
|
|
350
404
|
startWebSocketServer,
|
|
351
|
-
startStdioTransport: () =>
|
|
405
|
+
startStdioTransport: async () => {
|
|
406
|
+
await start_stdio_transport(createMcpServer, getLog());
|
|
407
|
+
},
|
|
352
408
|
startHttpServer: async (httpPort, config, features) => {
|
|
353
|
-
const started = await startHttpServer(
|
|
409
|
+
const started = await startHttpServer(createMcpServer, disposeMcpServer, getLog(), httpPort, config, features);
|
|
354
410
|
httpServer = started.server;
|
|
355
411
|
return started;
|
|
356
412
|
},
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
import { createDrawioMcpApp } from "./index.js";
|
|
5
|
+
import { MemoryLogger } from "./real-environment/logger.js";
|
|
6
|
+
describe("multi-transport support", () => {
|
|
7
|
+
let app;
|
|
8
|
+
let logger;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
logger = new MemoryLogger();
|
|
11
|
+
app = createDrawioMcpApp({ log: logger });
|
|
12
|
+
});
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await app.close();
|
|
15
|
+
});
|
|
16
|
+
it("createMcpServer returns distinct instances", () => {
|
|
17
|
+
const server1 = app.createMcpServer();
|
|
18
|
+
const server2 = app.createMcpServer();
|
|
19
|
+
expect(server1).not.toBe(server2);
|
|
20
|
+
});
|
|
21
|
+
it("each McpServer instance has tools registered", async () => {
|
|
22
|
+
const [ct1, st1] = InMemoryTransport.createLinkedPair();
|
|
23
|
+
const [ct2, st2] = InMemoryTransport.createLinkedPair();
|
|
24
|
+
const server1 = app.createMcpServer();
|
|
25
|
+
const server2 = app.createMcpServer();
|
|
26
|
+
const client1 = new Client({ name: "test-client-1", version: "1.0.0" });
|
|
27
|
+
const client2 = new Client({ name: "test-client-2", version: "1.0.0" });
|
|
28
|
+
await Promise.all([
|
|
29
|
+
server1.connect(st1),
|
|
30
|
+
client1.connect(ct1),
|
|
31
|
+
server2.connect(st2),
|
|
32
|
+
client2.connect(ct2),
|
|
33
|
+
]);
|
|
34
|
+
const tools1 = await client1.listTools();
|
|
35
|
+
const tools2 = await client2.listTools();
|
|
36
|
+
expect(tools1.tools.length).toBeGreaterThan(0);
|
|
37
|
+
expect(tools2.tools.length).toBeGreaterThan(0);
|
|
38
|
+
const names1 = tools1.tools.map((t) => t.name).sort();
|
|
39
|
+
const names2 = tools2.tools.map((t) => t.name).sort();
|
|
40
|
+
expect(names1).toEqual(names2);
|
|
41
|
+
await client1.close();
|
|
42
|
+
await client2.close();
|
|
43
|
+
});
|
|
44
|
+
it("two InMemoryTransport connections work simultaneously without 'Already connected' error", async () => {
|
|
45
|
+
const [ct1, st1] = InMemoryTransport.createLinkedPair();
|
|
46
|
+
const [ct2, st2] = InMemoryTransport.createLinkedPair();
|
|
47
|
+
const server1 = app.createMcpServer();
|
|
48
|
+
const server2 = app.createMcpServer();
|
|
49
|
+
const client1 = new Client({ name: "test-client-1", version: "1.0.0" });
|
|
50
|
+
const client2 = new Client({ name: "test-client-2", version: "1.0.0" });
|
|
51
|
+
// This is the exact scenario that was failing before the fix:
|
|
52
|
+
// connecting two transports should not throw.
|
|
53
|
+
await expect(Promise.all([
|
|
54
|
+
server1.connect(st1),
|
|
55
|
+
client1.connect(ct1),
|
|
56
|
+
server2.connect(st2),
|
|
57
|
+
client2.connect(ct2),
|
|
58
|
+
])).resolves.not.toThrow();
|
|
59
|
+
// Both clients can independently list tools
|
|
60
|
+
const [tools1, tools2] = await Promise.all([
|
|
61
|
+
client1.listTools(),
|
|
62
|
+
client2.listTools(),
|
|
63
|
+
]);
|
|
64
|
+
expect(tools1.tools.length).toBeGreaterThan(0);
|
|
65
|
+
expect(tools2.tools.length).toBeGreaterThan(0);
|
|
66
|
+
await client1.close();
|
|
67
|
+
await client2.close();
|
|
68
|
+
});
|
|
69
|
+
it("close() shuts down all created McpServer instances", async () => {
|
|
70
|
+
const [ct1, st1] = InMemoryTransport.createLinkedPair();
|
|
71
|
+
const [ct2, st2] = InMemoryTransport.createLinkedPair();
|
|
72
|
+
const server1 = app.createMcpServer();
|
|
73
|
+
const server2 = app.createMcpServer();
|
|
74
|
+
const client1 = new Client({ name: "test-client-1", version: "1.0.0" });
|
|
75
|
+
const client2 = new Client({ name: "test-client-2", version: "1.0.0" });
|
|
76
|
+
await Promise.all([
|
|
77
|
+
server1.connect(st1),
|
|
78
|
+
client1.connect(ct1),
|
|
79
|
+
server2.connect(st2),
|
|
80
|
+
client2.connect(ct2),
|
|
81
|
+
]);
|
|
82
|
+
// close() should succeed without errors even with multiple servers
|
|
83
|
+
await expect(app.close()).resolves.not.toThrow();
|
|
84
|
+
// After close, listing tools should fail because the servers are shut down
|
|
85
|
+
await expect(client1.listTools()).rejects.toThrow();
|
|
86
|
+
await expect(client2.listTools()).rejects.toThrow();
|
|
87
|
+
});
|
|
88
|
+
it("connecting the same McpServer instance to two transports still throws", async () => {
|
|
89
|
+
const [_ct1, st1] = InMemoryTransport.createLinkedPair();
|
|
90
|
+
const [_ct2, st2] = InMemoryTransport.createLinkedPair();
|
|
91
|
+
const server = app.createMcpServer();
|
|
92
|
+
await server.connect(st1);
|
|
93
|
+
// The SDK constraint hasn't changed — a single Protocol instance
|
|
94
|
+
// still rejects a second connect().
|
|
95
|
+
await expect(server.connect(st2)).rejects.toThrow(/already connected/i);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe("HTTP transport (stateless per-request)", () => {
|
|
99
|
+
let app;
|
|
100
|
+
let logger;
|
|
101
|
+
let httpServer;
|
|
102
|
+
let port;
|
|
103
|
+
const config = {
|
|
104
|
+
extensionPort: 0,
|
|
105
|
+
httpPort: 0,
|
|
106
|
+
transports: ["http"],
|
|
107
|
+
editorEnabled: false,
|
|
108
|
+
};
|
|
109
|
+
const features = {
|
|
110
|
+
enableMcp: true,
|
|
111
|
+
enableEditor: false,
|
|
112
|
+
enableHealth: false,
|
|
113
|
+
enableConfig: false,
|
|
114
|
+
};
|
|
115
|
+
beforeEach(async () => {
|
|
116
|
+
logger = new MemoryLogger();
|
|
117
|
+
app = createDrawioMcpApp({ log: logger });
|
|
118
|
+
const started = await app.startHttpServer(0, config, features);
|
|
119
|
+
httpServer = started.server;
|
|
120
|
+
port = started.port;
|
|
121
|
+
});
|
|
122
|
+
afterEach(async () => {
|
|
123
|
+
await app.close();
|
|
124
|
+
});
|
|
125
|
+
it("handles a single HTTP client request", async () => {
|
|
126
|
+
const client = new Client({ name: "http-test-1", version: "1.0.0" });
|
|
127
|
+
const transport = new StreamableHTTPClientTransport(new URL(`http://localhost:${port}/mcp`));
|
|
128
|
+
await client.connect(transport);
|
|
129
|
+
const tools = await client.listTools();
|
|
130
|
+
expect(tools.tools.length).toBeGreaterThan(0);
|
|
131
|
+
await client.close();
|
|
132
|
+
});
|
|
133
|
+
it("handles multiple sequential HTTP client requests without reuse error", async () => {
|
|
134
|
+
// First request
|
|
135
|
+
const client1 = new Client({ name: "http-test-seq-1", version: "1.0.0" });
|
|
136
|
+
const transport1 = new StreamableHTTPClientTransport(new URL(`http://localhost:${port}/mcp`));
|
|
137
|
+
await client1.connect(transport1);
|
|
138
|
+
const tools1 = await client1.listTools();
|
|
139
|
+
expect(tools1.tools.length).toBeGreaterThan(0);
|
|
140
|
+
await client1.close();
|
|
141
|
+
// Second request — this was the exact scenario that triggered the
|
|
142
|
+
// "Stateless transport cannot be reused across requests" error.
|
|
143
|
+
const client2 = new Client({ name: "http-test-seq-2", version: "1.0.0" });
|
|
144
|
+
const transport2 = new StreamableHTTPClientTransport(new URL(`http://localhost:${port}/mcp`));
|
|
145
|
+
await client2.connect(transport2);
|
|
146
|
+
const tools2 = await client2.listTools();
|
|
147
|
+
expect(tools2.tools.length).toBeGreaterThan(0);
|
|
148
|
+
await client2.close();
|
|
149
|
+
});
|
|
150
|
+
it("close() succeeds after HTTP requests (per-request servers are cleaned up)", async () => {
|
|
151
|
+
const client = new Client({ name: "http-cleanup", version: "1.0.0" });
|
|
152
|
+
const transport = new StreamableHTTPClientTransport(new URL(`http://localhost:${port}/mcp`));
|
|
153
|
+
await client.connect(transport);
|
|
154
|
+
await client.listTools();
|
|
155
|
+
await client.close();
|
|
156
|
+
// close() should not hang or throw — the per-request McpServer
|
|
157
|
+
// was already disposed and removed from the tracking set.
|
|
158
|
+
await expect(app.close()).resolves.not.toThrow();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createRealEnvironmentContext, disposeRealEnvironmentContext, resetDiagram, selectCell, } from "./harness.js";
|
|
6
|
+
import { expectNoBrowserErrors, expectNoServerErrors, withVerificationScreenshot, } from "./assertions.js";
|
|
7
|
+
import { callToolJson, callToolRaw } from "./tools.js";
|
|
8
|
+
import { expectToolSuccess } from "./test-helpers.js";
|
|
9
|
+
function findText(content, substring) {
|
|
10
|
+
return content.find((item) => item.type === "text" && item.text?.includes(substring));
|
|
11
|
+
}
|
|
12
|
+
function parseDimensions(metaText) {
|
|
13
|
+
const match = metaText.match(/(\d+)x(\d+)/);
|
|
14
|
+
return {
|
|
15
|
+
width: match ? parseInt(match[1], 10) : 0,
|
|
16
|
+
height: match ? parseInt(match[2], 10) : 0,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
async function saveArtifact(artifactRunDir, name, data) {
|
|
20
|
+
const filePath = join(artifactRunDir, name);
|
|
21
|
+
await writeFile(filePath, data);
|
|
22
|
+
return filePath;
|
|
23
|
+
}
|
|
24
|
+
describe("real environment/export-diagram", () => {
|
|
25
|
+
let context;
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
context = await createRealEnvironmentContext();
|
|
28
|
+
}, 180000);
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
await disposeRealEnvironmentContext(context);
|
|
31
|
+
});
|
|
32
|
+
it("exports as XML with cell content, geometry, and style preserved", async () => {
|
|
33
|
+
await resetDiagram(context);
|
|
34
|
+
context.browserMessages.length = 0;
|
|
35
|
+
const logCountBefore = context.logger.entries.length;
|
|
36
|
+
const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
|
|
37
|
+
x: 100,
|
|
38
|
+
y: 100,
|
|
39
|
+
width: 200,
|
|
40
|
+
height: 100,
|
|
41
|
+
text: "XML Export Cell",
|
|
42
|
+
style: "fillColor=#dae8fc;strokeColor=#6c8ebf;whiteSpace=wrap;html=1;",
|
|
43
|
+
});
|
|
44
|
+
expectToolSuccess(rectangle);
|
|
45
|
+
const result = await callToolRaw(context, "export-diagram", {
|
|
46
|
+
format: "xml",
|
|
47
|
+
});
|
|
48
|
+
const content = result.content;
|
|
49
|
+
await withVerificationScreenshot(context, "export-xml", "before-live-state-verification", async () => {
|
|
50
|
+
const xmlContent = findText(content, "mxGraphModel");
|
|
51
|
+
expect(xmlContent).toBeDefined();
|
|
52
|
+
const xml = xmlContent.text;
|
|
53
|
+
expect(xml).toContain("XML Export Cell");
|
|
54
|
+
expect(xml).toContain(rectangle.result.id);
|
|
55
|
+
expect(xml).toContain("fillColor=#dae8fc");
|
|
56
|
+
expect(xml).toContain("strokeColor=#6c8ebf");
|
|
57
|
+
expect(xml).toContain('width="200"');
|
|
58
|
+
expect(xml).toContain('height="100"');
|
|
59
|
+
expect(xml).toContain('x="100"');
|
|
60
|
+
expect(xml).toContain('y="100"');
|
|
61
|
+
expect(xml).toContain("mxGeometry");
|
|
62
|
+
expect(xml).toContain('vertex="1"');
|
|
63
|
+
const metaContent = findText(content, "Exported xml");
|
|
64
|
+
expect(metaContent).toBeDefined();
|
|
65
|
+
expect(metaContent.text).toContain("application/xml");
|
|
66
|
+
await saveArtifact(context.artifactRunDir, "export-xml.xml", xml);
|
|
67
|
+
});
|
|
68
|
+
await expectNoBrowserErrors(context, "export-xml");
|
|
69
|
+
await expectNoServerErrors(context, "export-xml", logCountBefore);
|
|
70
|
+
}, 180000);
|
|
71
|
+
it("exports as SVG with renderable structure and positive dimensions", async () => {
|
|
72
|
+
await resetDiagram(context);
|
|
73
|
+
context.browserMessages.length = 0;
|
|
74
|
+
const logCountBefore = context.logger.entries.length;
|
|
75
|
+
const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
|
|
76
|
+
x: 50,
|
|
77
|
+
y: 50,
|
|
78
|
+
width: 160,
|
|
79
|
+
height: 80,
|
|
80
|
+
text: "SVG Export Cell",
|
|
81
|
+
});
|
|
82
|
+
expectToolSuccess(rectangle);
|
|
83
|
+
const result = await callToolRaw(context, "export-diagram", {
|
|
84
|
+
format: "svg",
|
|
85
|
+
scale: 1,
|
|
86
|
+
border: 10,
|
|
87
|
+
background: "#f0f0f0",
|
|
88
|
+
});
|
|
89
|
+
const content = result.content;
|
|
90
|
+
await withVerificationScreenshot(context, "export-svg", "before-live-state-verification", async () => {
|
|
91
|
+
const svgContent = findText(content, "<svg");
|
|
92
|
+
expect(svgContent).toBeDefined();
|
|
93
|
+
const svg = svgContent.text;
|
|
94
|
+
expect(svg).toContain("SVG Export Cell");
|
|
95
|
+
expect(svg).toContain("xmlns=");
|
|
96
|
+
expect(svg).toMatch(/width="\d+/);
|
|
97
|
+
expect(svg).toMatch(/height="\d+/);
|
|
98
|
+
expect(svg).toContain("</svg>");
|
|
99
|
+
const widthMatch = svg.match(/width="(\d+)/);
|
|
100
|
+
const heightMatch = svg.match(/height="(\d+)/);
|
|
101
|
+
expect(parseInt(widthMatch[1], 10)).toBeGreaterThan(0);
|
|
102
|
+
expect(parseInt(heightMatch[1], 10)).toBeGreaterThan(0);
|
|
103
|
+
const metaContent = findText(content, "Exported svg");
|
|
104
|
+
expect(metaContent).toBeDefined();
|
|
105
|
+
expect(metaContent.text).toContain("image/svg+xml");
|
|
106
|
+
const dims = parseDimensions(metaContent.text);
|
|
107
|
+
expect(dims.width).toBeGreaterThan(0);
|
|
108
|
+
expect(dims.height).toBeGreaterThan(0);
|
|
109
|
+
await saveArtifact(context.artifactRunDir, "export-svg.svg", svg);
|
|
110
|
+
});
|
|
111
|
+
await expectNoBrowserErrors(context, "export-svg");
|
|
112
|
+
await expectNoServerErrors(context, "export-svg", logCountBefore);
|
|
113
|
+
}, 180000);
|
|
114
|
+
it("exports as PNG with non-trivial image data and positive dimensions", async () => {
|
|
115
|
+
await resetDiagram(context);
|
|
116
|
+
context.browserMessages.length = 0;
|
|
117
|
+
const logCountBefore = context.logger.entries.length;
|
|
118
|
+
const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
|
|
119
|
+
x: 80,
|
|
120
|
+
y: 80,
|
|
121
|
+
width: 140,
|
|
122
|
+
height: 70,
|
|
123
|
+
text: "PNG Export Cell",
|
|
124
|
+
});
|
|
125
|
+
expectToolSuccess(rectangle);
|
|
126
|
+
const result = await callToolRaw(context, "export-diagram", {
|
|
127
|
+
format: "png",
|
|
128
|
+
scale: 2,
|
|
129
|
+
dpi: 150,
|
|
130
|
+
});
|
|
131
|
+
const content = result.content;
|
|
132
|
+
await withVerificationScreenshot(context, "export-png", "before-live-state-verification", async () => {
|
|
133
|
+
const imageContent = content.find((item) => item.type === "image");
|
|
134
|
+
expect(imageContent).toBeDefined();
|
|
135
|
+
expect(imageContent.mimeType).toBe("image/png");
|
|
136
|
+
expect(imageContent.data).toBeTruthy();
|
|
137
|
+
const pngBuffer = Buffer.from(imageContent.data, "base64");
|
|
138
|
+
expect(pngBuffer[0]).toBe(0x89);
|
|
139
|
+
expect(pngBuffer[1]).toBe(0x50);
|
|
140
|
+
expect(pngBuffer[2]).toBe(0x4e);
|
|
141
|
+
expect(pngBuffer[3]).toBe(0x47);
|
|
142
|
+
expect(pngBuffer.length).toBeGreaterThan(500);
|
|
143
|
+
const metaContent = content.find((item) => item.type === "text" &&
|
|
144
|
+
item.text?.includes("Exported png"));
|
|
145
|
+
expect(metaContent).toBeDefined();
|
|
146
|
+
expect(metaContent.text).toContain("image/png");
|
|
147
|
+
const dims = parseDimensions(metaContent.text);
|
|
148
|
+
expect(dims.width).toBeGreaterThan(0);
|
|
149
|
+
expect(dims.height).toBeGreaterThan(0);
|
|
150
|
+
await saveArtifact(context.artifactRunDir, "export-png.png", pngBuffer);
|
|
151
|
+
});
|
|
152
|
+
await expectNoBrowserErrors(context, "export-png");
|
|
153
|
+
await expectNoServerErrors(context, "export-png", logCountBefore);
|
|
154
|
+
}, 180000);
|
|
155
|
+
it("exports XML to a file and file matches returned content", async () => {
|
|
156
|
+
await resetDiagram(context);
|
|
157
|
+
context.browserMessages.length = 0;
|
|
158
|
+
const logCountBefore = context.logger.entries.length;
|
|
159
|
+
const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
|
|
160
|
+
x: 100,
|
|
161
|
+
y: 100,
|
|
162
|
+
width: 180,
|
|
163
|
+
height: 90,
|
|
164
|
+
text: "File Export Cell",
|
|
165
|
+
style: "fillColor=#ffe6cc;strokeColor=#d79b00;whiteSpace=wrap;html=1;",
|
|
166
|
+
});
|
|
167
|
+
expectToolSuccess(rectangle);
|
|
168
|
+
const exportPath = join(context.artifactRunDir, "export-xml-file.xml");
|
|
169
|
+
const result = await callToolRaw(context, "export-diagram", {
|
|
170
|
+
format: "xml",
|
|
171
|
+
output_path: exportPath,
|
|
172
|
+
});
|
|
173
|
+
const content = result.content;
|
|
174
|
+
await withVerificationScreenshot(context, "export-xml-file", "before-live-state-verification", async () => {
|
|
175
|
+
expect(existsSync(exportPath)).toBe(true);
|
|
176
|
+
const fileContent = readFileSync(exportPath, "utf-8");
|
|
177
|
+
expect(fileContent).toContain("mxGraphModel");
|
|
178
|
+
expect(fileContent).toContain("File Export Cell");
|
|
179
|
+
expect(fileContent).toContain(rectangle.result.id);
|
|
180
|
+
expect(fileContent).toContain("fillColor=#ffe6cc");
|
|
181
|
+
expect(fileContent).toContain('width="180"');
|
|
182
|
+
expect(fileContent).toContain('height="90"');
|
|
183
|
+
const returnedXml = findText(content, "mxGraphModel");
|
|
184
|
+
expect(returnedXml).toBeDefined();
|
|
185
|
+
expect(fileContent).toBe(returnedXml.text);
|
|
186
|
+
const savedContent = findText(content, "Saved to:");
|
|
187
|
+
expect(savedContent).toBeDefined();
|
|
188
|
+
expect(savedContent.text).toContain(exportPath);
|
|
189
|
+
});
|
|
190
|
+
await expectNoBrowserErrors(context, "export-xml-file");
|
|
191
|
+
await expectNoServerErrors(context, "export-xml-file", logCountBefore);
|
|
192
|
+
}, 180000);
|
|
193
|
+
it("exports SVG to a file with valid SVG structure", async () => {
|
|
194
|
+
await resetDiagram(context);
|
|
195
|
+
context.browserMessages.length = 0;
|
|
196
|
+
const logCountBefore = context.logger.entries.length;
|
|
197
|
+
const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
|
|
198
|
+
x: 60,
|
|
199
|
+
y: 60,
|
|
200
|
+
width: 150,
|
|
201
|
+
height: 80,
|
|
202
|
+
text: "SVG File Cell",
|
|
203
|
+
});
|
|
204
|
+
expectToolSuccess(rectangle);
|
|
205
|
+
const exportPath = join(context.artifactRunDir, "export-svg-file.svg");
|
|
206
|
+
const result = await callToolRaw(context, "export-diagram", {
|
|
207
|
+
format: "svg",
|
|
208
|
+
output_path: exportPath,
|
|
209
|
+
});
|
|
210
|
+
const content = result.content;
|
|
211
|
+
await withVerificationScreenshot(context, "export-svg-file", "before-live-state-verification", async () => {
|
|
212
|
+
expect(existsSync(exportPath)).toBe(true);
|
|
213
|
+
const fileContent = readFileSync(exportPath, "utf-8");
|
|
214
|
+
expect(fileContent).toContain("<svg");
|
|
215
|
+
expect(fileContent).toContain("</svg>");
|
|
216
|
+
expect(fileContent).toContain("SVG File Cell");
|
|
217
|
+
expect(fileContent).toContain("xmlns=");
|
|
218
|
+
expect(fileContent).toMatch(/width="\d+/);
|
|
219
|
+
expect(fileContent).toMatch(/height="\d+/);
|
|
220
|
+
const returnedSvg = findText(content, "<svg");
|
|
221
|
+
expect(returnedSvg).toBeDefined();
|
|
222
|
+
expect(fileContent).toBe(returnedSvg.text);
|
|
223
|
+
const savedContent = findText(content, "Saved to:");
|
|
224
|
+
expect(savedContent).toBeDefined();
|
|
225
|
+
});
|
|
226
|
+
await expectNoBrowserErrors(context, "export-svg-file");
|
|
227
|
+
await expectNoServerErrors(context, "export-svg-file", logCountBefore);
|
|
228
|
+
}, 180000);
|
|
229
|
+
it("exports PNG to a file with valid PNG binary data", async () => {
|
|
230
|
+
await resetDiagram(context);
|
|
231
|
+
context.browserMessages.length = 0;
|
|
232
|
+
const logCountBefore = context.logger.entries.length;
|
|
233
|
+
const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
|
|
234
|
+
x: 70,
|
|
235
|
+
y: 70,
|
|
236
|
+
width: 130,
|
|
237
|
+
height: 65,
|
|
238
|
+
text: "PNG File Cell",
|
|
239
|
+
});
|
|
240
|
+
expectToolSuccess(rectangle);
|
|
241
|
+
const exportPath = join(context.artifactRunDir, "export-png-file.png");
|
|
242
|
+
const result = await callToolRaw(context, "export-diagram", {
|
|
243
|
+
format: "png",
|
|
244
|
+
output_path: exportPath,
|
|
245
|
+
});
|
|
246
|
+
const content = result.content;
|
|
247
|
+
await withVerificationScreenshot(context, "export-png-file", "before-live-state-verification", async () => {
|
|
248
|
+
expect(existsSync(exportPath)).toBe(true);
|
|
249
|
+
const fileBuffer = readFileSync(exportPath);
|
|
250
|
+
expect(fileBuffer[0]).toBe(0x89);
|
|
251
|
+
expect(fileBuffer[1]).toBe(0x50);
|
|
252
|
+
expect(fileBuffer[2]).toBe(0x4e);
|
|
253
|
+
expect(fileBuffer[3]).toBe(0x47);
|
|
254
|
+
expect(fileBuffer.length).toBeGreaterThan(500);
|
|
255
|
+
const imageContent = content.find((item) => item.type === "image");
|
|
256
|
+
expect(imageContent).toBeDefined();
|
|
257
|
+
const returnedBuffer = Buffer.from(imageContent.data, "base64");
|
|
258
|
+
expect(fileBuffer.equals(returnedBuffer)).toBe(true);
|
|
259
|
+
const savedContent = content.find((item) => item.type === "text" &&
|
|
260
|
+
item.text?.includes("Saved to:"));
|
|
261
|
+
expect(savedContent).toBeDefined();
|
|
262
|
+
});
|
|
263
|
+
await expectNoBrowserErrors(context, "export-png-file");
|
|
264
|
+
await expectNoServerErrors(context, "export-png-file", logCountBefore);
|
|
265
|
+
}, 180000);
|
|
266
|
+
it("exports with transparent background and SVG has no background rect", async () => {
|
|
267
|
+
await resetDiagram(context);
|
|
268
|
+
context.browserMessages.length = 0;
|
|
269
|
+
const logCountBefore = context.logger.entries.length;
|
|
270
|
+
const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
|
|
271
|
+
x: 100,
|
|
272
|
+
y: 100,
|
|
273
|
+
width: 120,
|
|
274
|
+
height: 60,
|
|
275
|
+
text: "Transparent BG",
|
|
276
|
+
});
|
|
277
|
+
expectToolSuccess(rectangle);
|
|
278
|
+
const opaqueResult = await callToolRaw(context, "export-diagram", {
|
|
279
|
+
format: "svg",
|
|
280
|
+
transparent: false,
|
|
281
|
+
background: "#ff0000",
|
|
282
|
+
});
|
|
283
|
+
const opaqueContent = opaqueResult.content;
|
|
284
|
+
const opaqueSvg = findText(opaqueContent, "<svg");
|
|
285
|
+
const transparentResult = await callToolRaw(context, "export-diagram", {
|
|
286
|
+
format: "svg",
|
|
287
|
+
transparent: true,
|
|
288
|
+
});
|
|
289
|
+
const transparentContent = transparentResult.content;
|
|
290
|
+
await withVerificationScreenshot(context, "export-transparent", "before-live-state-verification", async () => {
|
|
291
|
+
const svgContent = findText(transparentContent, "<svg");
|
|
292
|
+
expect(svgContent).toBeDefined();
|
|
293
|
+
expect(svgContent.text).toContain("Transparent BG");
|
|
294
|
+
expect(svgContent.text).toContain("</svg>");
|
|
295
|
+
expect(svgContent.text).not.toContain("#ff0000");
|
|
296
|
+
expect(opaqueSvg).toBeDefined();
|
|
297
|
+
expect(opaqueSvg.text).toContain("#ff0000");
|
|
298
|
+
const metaContent = findText(transparentContent, "Exported svg");
|
|
299
|
+
expect(metaContent).toBeDefined();
|
|
300
|
+
await saveArtifact(context.artifactRunDir, "export-transparent-opaque.svg", opaqueSvg.text);
|
|
301
|
+
await saveArtifact(context.artifactRunDir, "export-transparent-transparent.svg", svgContent.text);
|
|
302
|
+
});
|
|
303
|
+
await expectNoBrowserErrors(context, "export-transparent");
|
|
304
|
+
await expectNoServerErrors(context, "export-transparent", logCountBefore);
|
|
305
|
+
}, 180000);
|
|
306
|
+
it("exports SVG with embedded XML containing diagram data", async () => {
|
|
307
|
+
await resetDiagram(context);
|
|
308
|
+
context.browserMessages.length = 0;
|
|
309
|
+
const logCountBefore = context.logger.entries.length;
|
|
310
|
+
const { payload: rectangle } = await callToolJson(context, "add-rectangle", {
|
|
311
|
+
x: 90,
|
|
312
|
+
y: 90,
|
|
313
|
+
width: 150,
|
|
314
|
+
height: 75,
|
|
315
|
+
text: "Embed XML Cell",
|
|
316
|
+
});
|
|
317
|
+
expectToolSuccess(rectangle);
|
|
318
|
+
const plainResult = await callToolRaw(context, "export-diagram", {
|
|
319
|
+
format: "svg",
|
|
320
|
+
embed_xml: false,
|
|
321
|
+
});
|
|
322
|
+
const plainContent = plainResult.content;
|
|
323
|
+
const embeddedResult = await callToolRaw(context, "export-diagram", {
|
|
324
|
+
format: "svg",
|
|
325
|
+
embed_xml: true,
|
|
326
|
+
});
|
|
327
|
+
const embeddedContent = embeddedResult.content;
|
|
328
|
+
await withVerificationScreenshot(context, "export-svg-embed-xml", "before-live-state-verification", async () => {
|
|
329
|
+
const embeddedSvg = findText(embeddedContent, "Embed XML Cell");
|
|
330
|
+
expect(embeddedSvg).toBeDefined();
|
|
331
|
+
const embeddedData = embeddedSvg.text;
|
|
332
|
+
expect(embeddedData).toContain("mxGraphModel");
|
|
333
|
+
expect(embeddedData).toContain("mxfile");
|
|
334
|
+
const plainSvg = findText(plainContent, "<svg");
|
|
335
|
+
expect(plainSvg).toBeDefined();
|
|
336
|
+
expect(plainSvg.text).not.toContain("mxfile");
|
|
337
|
+
expect(embeddedData.length).toBeGreaterThan(plainSvg.text.length);
|
|
338
|
+
const metaContent = findText(embeddedContent, "Exported svg");
|
|
339
|
+
expect(metaContent).toBeDefined();
|
|
340
|
+
expect(metaContent.text).toContain("image/svg+xml");
|
|
341
|
+
await saveArtifact(context.artifactRunDir, "export-svg-plain.svg", plainSvg.text);
|
|
342
|
+
await saveArtifact(context.artifactRunDir, "export-svg-embedded.svg", embeddedData);
|
|
343
|
+
});
|
|
344
|
+
await expectNoBrowserErrors(context, "export-svg-embed-xml");
|
|
345
|
+
await expectNoServerErrors(context, "export-svg-embed-xml", logCountBefore);
|
|
346
|
+
}, 180000);
|
|
347
|
+
it("exports only the selected cell in SVG and excludes unselected cells", async () => {
|
|
348
|
+
await resetDiagram(context);
|
|
349
|
+
context.browserMessages.length = 0;
|
|
350
|
+
const logCountBefore = context.logger.entries.length;
|
|
351
|
+
const { payload: selectedRect } = await callToolJson(context, "add-rectangle", {
|
|
352
|
+
x: 100,
|
|
353
|
+
y: 100,
|
|
354
|
+
width: 140,
|
|
355
|
+
height: 70,
|
|
356
|
+
text: "Selected Cell",
|
|
357
|
+
});
|
|
358
|
+
expectToolSuccess(selectedRect);
|
|
359
|
+
const { payload: otherRect } = await callToolJson(context, "add-rectangle", {
|
|
360
|
+
x: 350,
|
|
361
|
+
y: 100,
|
|
362
|
+
width: 140,
|
|
363
|
+
height: 70,
|
|
364
|
+
text: "Unselected Cell",
|
|
365
|
+
});
|
|
366
|
+
expectToolSuccess(otherRect);
|
|
367
|
+
await selectCell(context.page, selectedRect.result.id);
|
|
368
|
+
// SVG export respects selection_only by rendering only the selected cells
|
|
369
|
+
const selectionResult = await callToolRaw(context, "export-diagram", {
|
|
370
|
+
format: "svg",
|
|
371
|
+
selection_only: true,
|
|
372
|
+
size: "selection",
|
|
373
|
+
});
|
|
374
|
+
const selectionContent = selectionResult.content;
|
|
375
|
+
const fullResult = await callToolRaw(context, "export-diagram", {
|
|
376
|
+
format: "svg",
|
|
377
|
+
});
|
|
378
|
+
const fullContent = fullResult.content;
|
|
379
|
+
await withVerificationScreenshot(context, "export-selection-only", "before-live-state-verification", async () => {
|
|
380
|
+
const selectionSvg = findText(selectionContent, "<svg");
|
|
381
|
+
expect(selectionSvg).toBeDefined();
|
|
382
|
+
expect(selectionSvg.text).toContain("Selected Cell");
|
|
383
|
+
expect(selectionSvg.text).not.toContain("Unselected Cell");
|
|
384
|
+
const fullSvg = findText(fullContent, "<svg");
|
|
385
|
+
expect(fullSvg).toBeDefined();
|
|
386
|
+
expect(fullSvg.text).toContain("Selected Cell");
|
|
387
|
+
expect(fullSvg.text).toContain("Unselected Cell");
|
|
388
|
+
await saveArtifact(context.artifactRunDir, "export-selection-only.svg", selectionSvg.text);
|
|
389
|
+
await saveArtifact(context.artifactRunDir, "export-selection-full.svg", fullSvg.text);
|
|
390
|
+
});
|
|
391
|
+
await expectNoBrowserErrors(context, "export-selection-only");
|
|
392
|
+
await expectNoServerErrors(context, "export-selection-only", logCountBefore);
|
|
393
|
+
}, 180000);
|
|
394
|
+
it("exports multiple cells and all appear in the output", async () => {
|
|
395
|
+
await resetDiagram(context);
|
|
396
|
+
context.browserMessages.length = 0;
|
|
397
|
+
const logCountBefore = context.logger.entries.length;
|
|
398
|
+
const cellNames = ["Alpha", "Beta", "Gamma"];
|
|
399
|
+
const cellIds = [];
|
|
400
|
+
for (let i = 0; i < cellNames.length; i++) {
|
|
401
|
+
const { payload } = await callToolJson(context, "add-rectangle", {
|
|
402
|
+
x: 50 + i * 200,
|
|
403
|
+
y: 100,
|
|
404
|
+
width: 140,
|
|
405
|
+
height: 70,
|
|
406
|
+
text: cellNames[i],
|
|
407
|
+
});
|
|
408
|
+
expectToolSuccess(payload);
|
|
409
|
+
cellIds.push(payload.result.id);
|
|
410
|
+
}
|
|
411
|
+
const xmlResult = await callToolRaw(context, "export-diagram", {
|
|
412
|
+
format: "xml",
|
|
413
|
+
});
|
|
414
|
+
const xmlContent = xmlResult.content;
|
|
415
|
+
const svgResult = await callToolRaw(context, "export-diagram", {
|
|
416
|
+
format: "svg",
|
|
417
|
+
});
|
|
418
|
+
const svgContent = svgResult.content;
|
|
419
|
+
const pngResult = await callToolRaw(context, "export-diagram", {
|
|
420
|
+
format: "png",
|
|
421
|
+
});
|
|
422
|
+
const pngContent = pngResult.content;
|
|
423
|
+
await withVerificationScreenshot(context, "export-multi-cell", "before-live-state-verification", async () => {
|
|
424
|
+
const xml = findText(xmlContent, "mxGraphModel");
|
|
425
|
+
expect(xml).toBeDefined();
|
|
426
|
+
for (const name of cellNames) {
|
|
427
|
+
expect(xml.text).toContain(name);
|
|
428
|
+
}
|
|
429
|
+
for (const id of cellIds) {
|
|
430
|
+
expect(xml.text).toContain(id);
|
|
431
|
+
}
|
|
432
|
+
const vertexMatches = xml.text.match(/vertex="1"/g);
|
|
433
|
+
expect(vertexMatches).not.toBeNull();
|
|
434
|
+
expect(vertexMatches.length).toBeGreaterThanOrEqual(cellNames.length);
|
|
435
|
+
const svg = findText(svgContent, "<svg");
|
|
436
|
+
expect(svg).toBeDefined();
|
|
437
|
+
for (const name of cellNames) {
|
|
438
|
+
expect(svg.text).toContain(name);
|
|
439
|
+
}
|
|
440
|
+
await saveArtifact(context.artifactRunDir, "export-multi-cell.xml", xml.text);
|
|
441
|
+
await saveArtifact(context.artifactRunDir, "export-multi-cell.svg", svg.text);
|
|
442
|
+
const pngImage = pngContent.find((item) => item.type === "image");
|
|
443
|
+
expect(pngImage).toBeDefined();
|
|
444
|
+
await saveArtifact(context.artifactRunDir, "export-multi-cell.png", Buffer.from(pngImage.data, "base64"));
|
|
445
|
+
});
|
|
446
|
+
await expectNoBrowserErrors(context, "export-multi-cell");
|
|
447
|
+
await expectNoServerErrors(context, "export-multi-cell", logCountBefore);
|
|
448
|
+
}, 180000);
|
|
449
|
+
it("exports an empty diagram with only root cells", async () => {
|
|
450
|
+
await resetDiagram(context);
|
|
451
|
+
context.browserMessages.length = 0;
|
|
452
|
+
const logCountBefore = context.logger.entries.length;
|
|
453
|
+
const result = await callToolRaw(context, "export-diagram", {
|
|
454
|
+
format: "xml",
|
|
455
|
+
});
|
|
456
|
+
const content = result.content;
|
|
457
|
+
await withVerificationScreenshot(context, "export-empty", "before-live-state-verification", async () => {
|
|
458
|
+
const xmlContent = findText(content, "mxGraphModel");
|
|
459
|
+
expect(xmlContent).toBeDefined();
|
|
460
|
+
const xml = xmlContent.text;
|
|
461
|
+
expect(xml).toContain("mxGraphModel");
|
|
462
|
+
expect(xml).toContain('<mxCell id="0"');
|
|
463
|
+
expect(xml).toContain('<mxCell id="1"');
|
|
464
|
+
expect(xml).not.toMatch(/vertex="1"/);
|
|
465
|
+
const metaContent = findText(content, "Exported xml");
|
|
466
|
+
expect(metaContent).toBeDefined();
|
|
467
|
+
expect(metaContent.text).toContain("application/xml");
|
|
468
|
+
await saveArtifact(context.artifactRunDir, "export-empty.xml", xml);
|
|
469
|
+
});
|
|
470
|
+
await expectNoBrowserErrors(context, "export-empty");
|
|
471
|
+
await expectNoServerErrors(context, "export-empty", logCountBefore);
|
|
472
|
+
}, 180000);
|
|
473
|
+
});
|
|
@@ -17,7 +17,7 @@ export async function createRealEnvironmentContext() {
|
|
|
17
17
|
const config = {
|
|
18
18
|
extensionPort: wsPort,
|
|
19
19
|
httpPort: 0,
|
|
20
|
-
transports: [
|
|
20
|
+
transports: [],
|
|
21
21
|
editorEnabled: true,
|
|
22
22
|
};
|
|
23
23
|
const startedHttp = await app.startHttpServer(0, config, getHttpFeatureConfig(config));
|
|
@@ -27,8 +27,9 @@ export async function createRealEnvironmentContext() {
|
|
|
27
27
|
name: "real-environment-test",
|
|
28
28
|
version: "1.0.0",
|
|
29
29
|
});
|
|
30
|
+
const testServer = app.createMcpServer();
|
|
30
31
|
await Promise.all([
|
|
31
|
-
|
|
32
|
+
testServer.connect(serverTransport),
|
|
32
33
|
client.connect(clientTransport),
|
|
33
34
|
]);
|
|
34
35
|
const browser = await chromium.launch({ headless: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drawio-mcp-server",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
4
4
|
"description": "Provides Draw.io services to MCP Clients",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -31,31 +31,31 @@
|
|
|
31
31
|
"url": "git+https://github.com/lgazo/drawio-mcp-server.git"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@hono/node-server": "1.19.
|
|
35
|
-
"@modelcontextprotocol/sdk": "1.
|
|
36
|
-
"cachedir": "
|
|
37
|
-
"hono": "4.
|
|
34
|
+
"@hono/node-server": "1.19.13",
|
|
35
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
36
|
+
"cachedir": "2.4.0",
|
|
37
|
+
"hono": "4.12.12",
|
|
38
38
|
"nanoid": "5.1.6",
|
|
39
|
-
"unzipper": "
|
|
39
|
+
"unzipper": "0.12.3",
|
|
40
40
|
"ws": "8.18.3",
|
|
41
41
|
"zod": "4.2.1"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@jest/globals": "30.2.0",
|
|
45
|
-
"@playwright/test": "1.
|
|
45
|
+
"@playwright/test": "1.59.1",
|
|
46
46
|
"@types/jest": "30.0.0",
|
|
47
47
|
"@types/node": "25.0.3",
|
|
48
|
-
"@types/unzipper": "
|
|
49
|
-
"@types/ws": "
|
|
50
|
-
"esbuild": "
|
|
48
|
+
"@types/unzipper": "0.10.11",
|
|
49
|
+
"@types/ws": "8.18.1",
|
|
50
|
+
"esbuild": "0.25.12",
|
|
51
51
|
"globals": "16.5.0",
|
|
52
52
|
"jest": "30.2.0",
|
|
53
53
|
"jest-environment-node": "30.2.0",
|
|
54
54
|
"prettier": "3.7.4",
|
|
55
|
-
"rimraf": "6.1.
|
|
56
|
-
"ts-jest": "29.4.
|
|
55
|
+
"rimraf": "6.1.3",
|
|
56
|
+
"ts-jest": "29.4.9",
|
|
57
57
|
"typescript": "5.9.3",
|
|
58
|
-
"drawio-mcp-plugin": "2.0.
|
|
58
|
+
"drawio-mcp-plugin": "2.0.1"
|
|
59
59
|
},
|
|
60
60
|
"scripts": {
|
|
61
61
|
"build": "rimraf build && tsc && mkdir -p build/plugin && cp node_modules/drawio-mcp-plugin/dist/mcp-plugin.js build/plugin/mcp-plugin.js",
|