drawio-mcp-server 1.8.0 → 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/README.md +9 -5
- package/build/emitter_bus.js +2 -3
- package/build/index.js +243 -341
- package/build/multi-transport.test.js +160 -0
- package/build/plugin/mcp-plugin.js +853 -60
- package/build/prefetch-assets.js +11 -0
- package/build/real-environment/add-cell-of-shape.test.js +51 -0
- package/build/real-environment/add-edge.test.js +86 -0
- package/build/real-environment/assertions.js +18 -0
- package/build/real-environment/delete-cell-by-id.test.js +45 -0
- package/build/real-environment/edge-editing.test.js +70 -0
- package/build/real-environment/edit-cell.test.js +64 -0
- package/build/real-environment/export-diagram.test.js +473 -0
- package/build/real-environment/harness.js +176 -0
- package/build/real-environment/import-export.test.js +82 -0
- package/build/real-environment/layers-and-selection.test.js +70 -0
- package/build/real-environment/logger.js +25 -0
- package/build/real-environment/screenshot.js +46 -0
- package/build/real-environment/set-cell-parent.test.js +64 -0
- package/build/real-environment/shapes.test.js +153 -0
- package/build/real-environment/test-helpers.js +10 -0
- package/build/real-environment/tools.js +22 -0
- package/build/real-environment/types.js +1 -0
- package/build/tool.js +46 -0
- package/build/tools/add-cell-of-shape.js +42 -0
- package/build/tools/add-edge.js +33 -0
- package/build/tools/add-rectangle.js +41 -0
- package/build/tools/create-layer.js +8 -0
- package/build/tools/delete-cell-by-id.js +10 -0
- package/build/tools/edit-cell.js +28 -0
- package/build/tools/edit-edge.js +30 -0
- package/build/tools/export-diagram.js +96 -0
- package/build/tools/get-active-layer.js +5 -0
- package/build/tools/get-selected-cell.js +5 -0
- package/build/tools/get-shape-by-name.js +10 -0
- package/build/tools/get-shape-categories.js +5 -0
- package/build/tools/get-shapes-in-category.js +10 -0
- package/build/tools/import-diagram.js +22 -0
- package/build/tools/index.js +49 -0
- package/build/tools/list-layers.js +5 -0
- package/build/tools/list-paged-model.js +41 -0
- package/build/tools/move-cell-to-layer.js +11 -0
- package/build/tools/set-active-layer.js +8 -0
- package/build/tools/set-cell-data.js +14 -0
- package/build/tools/set-cell-parent.js +9 -0
- package/build/tools/set-cell-shape.js +13 -0
- package/build/tools/shared.js +7 -0
- package/build/tools/types.js +1 -0
- package/package.json +35 -33
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { chromium, } from "@playwright/test";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
import { ensureAssets } from "../assets/index.js";
|
|
5
|
+
import { getHttpFeatureConfig } from "../config.js";
|
|
6
|
+
import { createDrawioMcpApp } from "../index.js";
|
|
7
|
+
import { MemoryLogger } from "./logger.js";
|
|
8
|
+
import { createArtifactRunDir } from "./screenshot.js";
|
|
9
|
+
export async function createRealEnvironmentContext() {
|
|
10
|
+
const logger = new MemoryLogger();
|
|
11
|
+
const browserMessages = [];
|
|
12
|
+
const artifactRunDir = await createArtifactRunDir();
|
|
13
|
+
await ensureAssets({}, () => undefined);
|
|
14
|
+
const app = createDrawioMcpApp({ log: logger });
|
|
15
|
+
const wsServer = await app.startWebSocketServer(0);
|
|
16
|
+
const wsPort = Number(wsServer.address().port);
|
|
17
|
+
const config = {
|
|
18
|
+
extensionPort: wsPort,
|
|
19
|
+
httpPort: 0,
|
|
20
|
+
transports: [],
|
|
21
|
+
editorEnabled: true,
|
|
22
|
+
};
|
|
23
|
+
const startedHttp = await app.startHttpServer(0, config, getHttpFeatureConfig(config));
|
|
24
|
+
const httpPort = startedHttp.port;
|
|
25
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
26
|
+
const client = new Client({
|
|
27
|
+
name: "real-environment-test",
|
|
28
|
+
version: "1.0.0",
|
|
29
|
+
});
|
|
30
|
+
const testServer = app.createMcpServer();
|
|
31
|
+
await Promise.all([
|
|
32
|
+
testServer.connect(serverTransport),
|
|
33
|
+
client.connect(clientTransport),
|
|
34
|
+
]);
|
|
35
|
+
const browser = await chromium.launch({ headless: true });
|
|
36
|
+
const page = await browser.newPage();
|
|
37
|
+
page.on("console", (message) => {
|
|
38
|
+
browserMessages.push({
|
|
39
|
+
type: message.type(),
|
|
40
|
+
text: message.text(),
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
await page.addInitScript((port) => {
|
|
44
|
+
const store = {
|
|
45
|
+
websocketPort: port,
|
|
46
|
+
};
|
|
47
|
+
window.__DRAWIO_MCP_TEST_HOOKS__ = true;
|
|
48
|
+
window.localStorage.setItem("drawio-mcp-plugin-config", JSON.stringify(store));
|
|
49
|
+
window.localStorage.setItem("drawio-mcp-config", JSON.stringify(store));
|
|
50
|
+
}, wsPort);
|
|
51
|
+
await page.goto(`http://localhost:${httpPort}/`, {
|
|
52
|
+
waitUntil: "domcontentloaded",
|
|
53
|
+
});
|
|
54
|
+
await waitForPluginReady(page);
|
|
55
|
+
return {
|
|
56
|
+
browser,
|
|
57
|
+
page,
|
|
58
|
+
client,
|
|
59
|
+
app,
|
|
60
|
+
logger,
|
|
61
|
+
browserMessages,
|
|
62
|
+
artifactRunDir,
|
|
63
|
+
httpPort,
|
|
64
|
+
wsPort,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export async function disposeRealEnvironmentContext(context) {
|
|
68
|
+
await context.client.close();
|
|
69
|
+
await context.browser.close();
|
|
70
|
+
await context.app.close();
|
|
71
|
+
}
|
|
72
|
+
export async function waitForPluginReady(page) {
|
|
73
|
+
await page.waitForFunction(() => {
|
|
74
|
+
const maybeWindow = window;
|
|
75
|
+
return Boolean(maybeWindow.ui?.editor?.graph);
|
|
76
|
+
});
|
|
77
|
+
await page.waitForFunction(() => {
|
|
78
|
+
const maybeWindow = window;
|
|
79
|
+
const graph = maybeWindow.ui?.editor?.graph;
|
|
80
|
+
return Boolean(graph?.getModel?.()?.cells);
|
|
81
|
+
});
|
|
82
|
+
await page.waitForFunction(() => document.querySelector("svg") !== null);
|
|
83
|
+
await page.waitForTimeout(1500);
|
|
84
|
+
}
|
|
85
|
+
export async function getCells(page) {
|
|
86
|
+
return page.evaluate(() => {
|
|
87
|
+
const maybeWindow = window;
|
|
88
|
+
const graph = maybeWindow.ui?.editor?.graph;
|
|
89
|
+
const model = graph?.getModel?.();
|
|
90
|
+
const cells = Object.values(model?.cells ?? {});
|
|
91
|
+
return cells
|
|
92
|
+
.filter((cell) => cell?.vertex && cell?.geometry)
|
|
93
|
+
.map((cell) => ({
|
|
94
|
+
id: String(cell.id),
|
|
95
|
+
value: typeof cell.value === "string" ? cell.value : "",
|
|
96
|
+
style: String(cell.style ?? ""),
|
|
97
|
+
x: typeof cell.geometry?.x === "number" ? cell.geometry.x : null,
|
|
98
|
+
y: typeof cell.geometry?.y === "number" ? cell.geometry.y : null,
|
|
99
|
+
width: typeof cell.geometry?.width === "number" ? cell.geometry.width : null,
|
|
100
|
+
height: typeof cell.geometry?.height === "number"
|
|
101
|
+
? cell.geometry.height
|
|
102
|
+
: null,
|
|
103
|
+
parentId: cell.parent?.id ? String(cell.parent.id) : null,
|
|
104
|
+
}))
|
|
105
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export async function getCellById(page, cellId) {
|
|
109
|
+
return page.evaluate((id) => {
|
|
110
|
+
const maybeWindow = window;
|
|
111
|
+
const graph = maybeWindow.ui?.editor?.graph;
|
|
112
|
+
const cell = graph?.getModel?.().getCell?.(id);
|
|
113
|
+
if (!cell) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
id: String(cell.id),
|
|
118
|
+
value: typeof cell.value === "string" ? cell.value : "",
|
|
119
|
+
style: String(cell.style ?? ""),
|
|
120
|
+
x: typeof cell.geometry?.x === "number" ? cell.geometry.x : null,
|
|
121
|
+
y: typeof cell.geometry?.y === "number" ? cell.geometry.y : null,
|
|
122
|
+
width: typeof cell.geometry?.width === "number" ? cell.geometry.width : null,
|
|
123
|
+
height: typeof cell.geometry?.height === "number" ? cell.geometry.height : null,
|
|
124
|
+
parentId: cell.parent?.id ? String(cell.parent.id) : null,
|
|
125
|
+
edge: Boolean(cell.edge),
|
|
126
|
+
vertex: Boolean(cell.vertex),
|
|
127
|
+
sourceId: cell.source?.id ? String(cell.source.id) : null,
|
|
128
|
+
targetId: cell.target?.id ? String(cell.target.id) : null,
|
|
129
|
+
points: Array.isArray(cell.geometry?.points)
|
|
130
|
+
? cell.geometry.points.map((point) => ({
|
|
131
|
+
x: typeof point.x === "number" ? point.x : null,
|
|
132
|
+
y: typeof point.y === "number" ? point.y : null,
|
|
133
|
+
}))
|
|
134
|
+
: [],
|
|
135
|
+
attributes: typeof cell.value === "object" && cell.value?.attributes
|
|
136
|
+
? Array.from(cell.value.attributes).reduce((acc, attr) => {
|
|
137
|
+
acc[String(attr.name)] = String(attr.value);
|
|
138
|
+
return acc;
|
|
139
|
+
}, {})
|
|
140
|
+
: {},
|
|
141
|
+
};
|
|
142
|
+
}, cellId);
|
|
143
|
+
}
|
|
144
|
+
export async function selectCell(page, cellId) {
|
|
145
|
+
await page.evaluate((id) => {
|
|
146
|
+
const maybeWindow = window;
|
|
147
|
+
const graph = maybeWindow.ui?.editor?.graph;
|
|
148
|
+
const cell = graph?.getModel?.().getCell?.(id);
|
|
149
|
+
if (!cell) {
|
|
150
|
+
throw new Error(`Cell ${id} not found for selection`);
|
|
151
|
+
}
|
|
152
|
+
graph.setSelectionCell(cell);
|
|
153
|
+
}, cellId);
|
|
154
|
+
}
|
|
155
|
+
export function browserErrors(context) {
|
|
156
|
+
return context.browserMessages.filter((entry) => entry.type === "error");
|
|
157
|
+
}
|
|
158
|
+
export async function resetDiagram(context) {
|
|
159
|
+
const emptyDiagram = '<mxGraphModel dx="0" dy="0" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0"><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>';
|
|
160
|
+
await context.client.callTool({
|
|
161
|
+
name: "import-diagram",
|
|
162
|
+
arguments: {
|
|
163
|
+
data: emptyDiagram,
|
|
164
|
+
format: "xml",
|
|
165
|
+
mode: "replace",
|
|
166
|
+
filename: "blank.drawio",
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
await context.page.waitForFunction(() => {
|
|
170
|
+
const maybeWindow = window;
|
|
171
|
+
const graph = maybeWindow.ui?.editor?.graph;
|
|
172
|
+
const model = graph?.getModel?.();
|
|
173
|
+
const cells = Object.values(model?.cells ?? {});
|
|
174
|
+
return cells.filter((cell) => cell?.vertex).length === 0;
|
|
175
|
+
});
|
|
176
|
+
}
|