drawio-mcp-server 2.0.4 → 2.1.0
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 +29 -3
- package/build/assets/downloader.js +16 -17
- package/build/config.js +253 -5
- package/build/config.test.js +449 -1
- package/build/emitter_bus.js +3 -0
- package/build/emitter_bus.test.js +7 -0
- package/build/index.capabilities.test.js +45 -0
- package/build/index.js +416 -82
- package/build/install-desktop-plugin.js +56 -0
- package/build/install-desktop-plugin.test.js +66 -0
- package/build/multi-transport.test.js +117 -0
- package/build/plugin/mcp-plugin.js +2180 -1105
- package/build/prefetch-assets.js +6 -5
- package/build/real-environment/document-targeting.test.js +124 -0
- package/build/real-environment/export-diagram.test.js +1 -1
- package/build/real-environment/harness.js +118 -31
- package/build/real-environment/import-mermaid.test.js +81 -0
- package/build/real-environment/pages-and-concurrency.test.js +448 -0
- package/build/real-environment/shapes.test.js +58 -0
- package/build/real-environment/tools.js +51 -6
- package/build/register-tool.js +31 -0
- package/build/request_queue.js +29 -0
- package/build/request_queue.test.js +98 -0
- package/build/stdio-transport-purity.test.js +88 -0
- package/build/strip-schema.js +61 -0
- package/build/tls/expiry.js +14 -0
- package/build/tls/expiry.test.js +54 -0
- package/build/tls/generate.js +123 -0
- package/build/tls/generate.test.js +115 -0
- package/build/tls/index.js +80 -0
- package/build/tls/index.test.js +141 -0
- package/build/tls/install-hint.js +45 -0
- package/build/tls/install-hint.test.js +32 -0
- package/build/tls/load.js +18 -0
- package/build/tls/load.test.js +40 -0
- package/build/tls/paths.js +30 -0
- package/build/tls/paths.test.js +53 -0
- package/build/tls/san.js +27 -0
- package/build/tls/san.test.js +72 -0
- package/build/tool-registry.test.js +823 -0
- package/build/tool.js +74 -15
- package/build/tool.test.js +250 -7
- package/build/tools/add-cell-of-shape.js +4 -2
- package/build/tools/add-edge.js +3 -1
- package/build/tools/add-rectangle.js +4 -2
- package/build/tools/copy-page.js +13 -0
- package/build/tools/create-layer.js +4 -2
- package/build/tools/create-page.js +8 -0
- package/build/tools/delete-cell-by-id.js +4 -2
- package/build/tools/edit-cell.js +3 -1
- package/build/tools/edit-edge.js +3 -1
- package/build/tools/export-diagram.js +7 -3
- package/build/tools/get-active-layer.js +4 -1
- package/build/tools/get-current-page.js +5 -0
- package/build/tools/get-selected-cell.js +4 -1
- package/build/tools/import-diagram.js +12 -2
- package/build/tools/import-mermaid.js +31 -0
- package/build/tools/index.js +14 -0
- package/build/tools/list-documents.js +17 -0
- package/build/tools/list-layers.js +4 -1
- package/build/tools/list-paged-model.js +4 -3
- package/build/tools/list-pages.js +5 -0
- package/build/tools/move-cell-to-layer.js +3 -1
- package/build/tools/rename-page.js +10 -0
- package/build/tools/set-active-layer.js +4 -2
- package/build/tools/set-cell-data.js +3 -1
- package/build/tools/set-cell-parent.js +3 -1
- package/build/tools/set-cell-shape.js +3 -1
- package/build/tools/shared.js +35 -0
- package/build/tools/shared.test.js +29 -0
- package/package.json +12 -4
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from "@jest/globals";
|
|
2
|
+
describe("shared tool registry", () => {
|
|
3
|
+
async function loadToolDefinitions() {
|
|
4
|
+
const registry = await import("drawio-mcp-plugin/dist/tool-registry.js");
|
|
5
|
+
return registry.toolDefinitions;
|
|
6
|
+
}
|
|
7
|
+
async function loadDrawioTools() {
|
|
8
|
+
return import("drawio-mcp-plugin/dist/drawio-tools.js");
|
|
9
|
+
}
|
|
10
|
+
async function activateDocument(documentId = "doc-1") {
|
|
11
|
+
const { set_active_document_id } = await loadDrawioTools();
|
|
12
|
+
set_active_document_id(documentId);
|
|
13
|
+
}
|
|
14
|
+
it("contains the new page management tools", async () => {
|
|
15
|
+
const toolDefinitions = await loadToolDefinitions();
|
|
16
|
+
const names = toolDefinitions.map((definition) => definition.name);
|
|
17
|
+
expect(names).toEqual(expect.arrayContaining([
|
|
18
|
+
"list-pages",
|
|
19
|
+
"get-current-page",
|
|
20
|
+
"create-page",
|
|
21
|
+
"copy-page",
|
|
22
|
+
"rename-page",
|
|
23
|
+
"import-mermaid",
|
|
24
|
+
]));
|
|
25
|
+
});
|
|
26
|
+
it("marks every live plugin tool with target_document", async () => {
|
|
27
|
+
const toolDefinitions = await loadToolDefinitions();
|
|
28
|
+
for (const definition of toolDefinitions) {
|
|
29
|
+
expect(definition.params.has("target_document")).toBe(true);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
it("marks page-scoped tools with target_page while preserving global tools", async () => {
|
|
33
|
+
const toolDefinitions = await loadToolDefinitions();
|
|
34
|
+
const registry = new Map(toolDefinitions.map((definition) => [definition.name, definition]));
|
|
35
|
+
expect(registry.get("add-rectangle")?.params.has("target_page")).toBe(true);
|
|
36
|
+
expect(registry.get("add-rectangle")?.params.has("target_document")).toBe(true);
|
|
37
|
+
expect(registry.get("list-paged-model")?.params.has("target_page")).toBe(true);
|
|
38
|
+
expect(registry.get("list-paged-model")?.params.has("target_document")).toBe(true);
|
|
39
|
+
expect(registry.get("rename-page")?.params.has("page")).toBe(true);
|
|
40
|
+
expect(registry.get("rename-page")?.params.has("target_document")).toBe(true);
|
|
41
|
+
expect(registry.get("copy-page")?.params.has("page")).toBe(true);
|
|
42
|
+
expect(registry.get("copy-page")?.params.has("target_document")).toBe(true);
|
|
43
|
+
expect(registry.get("import-mermaid")?.params.has("target_page")).toBe(true);
|
|
44
|
+
expect(registry.get("import-mermaid")?.params.has("target_document")).toBe(true);
|
|
45
|
+
expect(registry.get("get-shape-by-name")?.params.has("target_page")).toBe(false);
|
|
46
|
+
expect(registry.get("get-shape-by-name")?.params.has("target_document")).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it("classifies background-safe and UI-bound page tools in the shared registry", async () => {
|
|
49
|
+
const toolDefinitions = await loadToolDefinitions();
|
|
50
|
+
const registry = new Map(toolDefinitions.map((definition) => [definition.name, definition]));
|
|
51
|
+
expect(registry.get("add-rectangle")?.pageExecution?.mode).toBe("background-page");
|
|
52
|
+
expect(registry.get("get-selected-cell")?.pageExecution?.mode).toBe("visible-page");
|
|
53
|
+
expect(registry.get("export-diagram")?.pageExecution?.mode).toBe("hybrid-page");
|
|
54
|
+
expect(registry
|
|
55
|
+
.get("export-diagram")
|
|
56
|
+
?.pageExecution?.allow_background?.({ size: "page" })).toBe(true);
|
|
57
|
+
expect(registry.get("export-diagram")?.pageExecution?.allow_background?.({
|
|
58
|
+
format: "svg",
|
|
59
|
+
size: "page",
|
|
60
|
+
embed_xml: true,
|
|
61
|
+
})).toBe(true);
|
|
62
|
+
expect(registry.get("export-diagram")?.pageExecution?.allow_background?.({
|
|
63
|
+
format: "png",
|
|
64
|
+
size: "page",
|
|
65
|
+
embed_xml: true,
|
|
66
|
+
})).toBe(false);
|
|
67
|
+
expect(registry
|
|
68
|
+
.get("export-diagram")
|
|
69
|
+
?.pageExecution?.allow_background?.({ selection_only: true })).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
it("marks current pages by stable id instead of object identity", async () => {
|
|
72
|
+
const { serialize_page_info } = await loadDrawioTools();
|
|
73
|
+
const ui = {
|
|
74
|
+
currentPage: {
|
|
75
|
+
getId: () => "page-2",
|
|
76
|
+
getName: () => "Second",
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
const page = {
|
|
80
|
+
getId: () => "page-2",
|
|
81
|
+
getName: () => "Second",
|
|
82
|
+
};
|
|
83
|
+
expect(serialize_page_info(ui, page, 1).is_current).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
it("creates pages without switching the visible page when low-level page commands are available", async () => {
|
|
86
|
+
const { create_page } = await loadDrawioTools();
|
|
87
|
+
const originalChangePage = globalThis
|
|
88
|
+
.ChangePage;
|
|
89
|
+
globalThis.ChangePage =
|
|
90
|
+
function FakeChangePage(ui, page, _selectedPage, index, noSelect) {
|
|
91
|
+
this.ui = ui;
|
|
92
|
+
this.page = page;
|
|
93
|
+
this.index = index;
|
|
94
|
+
this.noSelect = noSelect;
|
|
95
|
+
this.execute = () => {
|
|
96
|
+
this.ui.pages.splice(this.index, 0, this.page);
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
try {
|
|
100
|
+
let createdName = "Second Page";
|
|
101
|
+
const currentPage = {
|
|
102
|
+
getId: () => "page-1",
|
|
103
|
+
getName: () => "First Page",
|
|
104
|
+
setName: jest.fn(),
|
|
105
|
+
};
|
|
106
|
+
const execute = jest.fn((command) => {
|
|
107
|
+
command.execute();
|
|
108
|
+
});
|
|
109
|
+
const ui = {
|
|
110
|
+
currentPage,
|
|
111
|
+
pages: [currentPage],
|
|
112
|
+
selectPage: jest.fn(),
|
|
113
|
+
createPageId: jest.fn(() => "page-2"),
|
|
114
|
+
createPage: jest.fn((name, id) => ({
|
|
115
|
+
getId: () => id,
|
|
116
|
+
getName: () => createdName,
|
|
117
|
+
setName: jest.fn((nextName) => {
|
|
118
|
+
createdName = nextName;
|
|
119
|
+
}),
|
|
120
|
+
})),
|
|
121
|
+
insertPage: jest.fn(),
|
|
122
|
+
editor: {
|
|
123
|
+
graph: {
|
|
124
|
+
isEnabled: jest.fn(() => true),
|
|
125
|
+
isEditing: jest.fn(() => false),
|
|
126
|
+
stopEditing: jest.fn(),
|
|
127
|
+
model: {
|
|
128
|
+
execute,
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
const result = create_page(ui, { name: "Second Page" });
|
|
134
|
+
expect(ui.createPage).toHaveBeenCalledWith("Second Page", "page-2");
|
|
135
|
+
expect(ui.insertPage).not.toHaveBeenCalled();
|
|
136
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(ui.selectPage).not.toHaveBeenCalled();
|
|
138
|
+
expect(ui.currentPage).toBe(currentPage);
|
|
139
|
+
expect(ui.pages).toHaveLength(2);
|
|
140
|
+
expect(result).toEqual({
|
|
141
|
+
index: 1,
|
|
142
|
+
id: "page-2",
|
|
143
|
+
name: "Second Page",
|
|
144
|
+
is_current: false,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
if (originalChangePage === undefined) {
|
|
149
|
+
delete globalThis.ChangePage;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
globalThis.ChangePage =
|
|
153
|
+
originalChangePage;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
it("does not use the create-page fast path when the graph is disabled", async () => {
|
|
158
|
+
const { create_page } = await loadDrawioTools();
|
|
159
|
+
const originalChangePage = globalThis
|
|
160
|
+
.ChangePage;
|
|
161
|
+
globalThis.ChangePage =
|
|
162
|
+
function FakeChangePage() {
|
|
163
|
+
this.execute = () => undefined;
|
|
164
|
+
};
|
|
165
|
+
try {
|
|
166
|
+
const currentPage = {
|
|
167
|
+
getId: () => "page-1",
|
|
168
|
+
getName: () => "First Page",
|
|
169
|
+
setName: jest.fn(),
|
|
170
|
+
};
|
|
171
|
+
const execute = jest.fn();
|
|
172
|
+
const ui = {
|
|
173
|
+
currentPage,
|
|
174
|
+
pages: [currentPage],
|
|
175
|
+
createPageId: jest.fn(() => "page-2"),
|
|
176
|
+
createPage: jest.fn(),
|
|
177
|
+
insertPage: jest.fn((_page, _index) => null),
|
|
178
|
+
editor: {
|
|
179
|
+
graph: {
|
|
180
|
+
isEnabled: jest.fn(() => false),
|
|
181
|
+
isEditing: jest.fn(() => false),
|
|
182
|
+
stopEditing: jest.fn(),
|
|
183
|
+
model: {
|
|
184
|
+
execute,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
expect(() => create_page(ui, { name: "Second Page" })).toThrow("Failed to create a new page");
|
|
190
|
+
expect(ui.createPageId).not.toHaveBeenCalled();
|
|
191
|
+
expect(ui.createPage).not.toHaveBeenCalled();
|
|
192
|
+
expect(execute).not.toHaveBeenCalled();
|
|
193
|
+
expect(ui.editor.graph.stopEditing).not.toHaveBeenCalled();
|
|
194
|
+
expect(ui.insertPage).toHaveBeenCalledWith(null, 1);
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
if (originalChangePage === undefined) {
|
|
198
|
+
delete globalThis.ChangePage;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
globalThis.ChangePage =
|
|
202
|
+
originalChangePage;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
it("stops active text editing before using the create-page fast path", async () => {
|
|
207
|
+
const { create_page } = await loadDrawioTools();
|
|
208
|
+
const originalChangePage = globalThis
|
|
209
|
+
.ChangePage;
|
|
210
|
+
globalThis.ChangePage =
|
|
211
|
+
function FakeChangePage(ui, page, _selectedPage, index) {
|
|
212
|
+
this.ui = ui;
|
|
213
|
+
this.page = page;
|
|
214
|
+
this.index = index;
|
|
215
|
+
this.execute = () => {
|
|
216
|
+
this.ui.pages.splice(this.index, 0, this.page);
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
try {
|
|
220
|
+
const currentPage = {
|
|
221
|
+
getId: () => "page-1",
|
|
222
|
+
getName: () => "First Page",
|
|
223
|
+
setName: jest.fn(),
|
|
224
|
+
};
|
|
225
|
+
const stopEditing = jest.fn();
|
|
226
|
+
const execute = jest.fn((command) => {
|
|
227
|
+
command.execute();
|
|
228
|
+
});
|
|
229
|
+
const ui = {
|
|
230
|
+
currentPage,
|
|
231
|
+
pages: [currentPage],
|
|
232
|
+
createPageId: jest.fn(() => "page-2"),
|
|
233
|
+
createPage: jest.fn((name, id) => ({
|
|
234
|
+
getId: () => id,
|
|
235
|
+
getName: () => name ?? "Page 2",
|
|
236
|
+
setName: jest.fn(),
|
|
237
|
+
})),
|
|
238
|
+
insertPage: jest.fn(),
|
|
239
|
+
editor: {
|
|
240
|
+
graph: {
|
|
241
|
+
isEnabled: jest.fn(() => true),
|
|
242
|
+
isEditing: jest.fn(() => true),
|
|
243
|
+
stopEditing,
|
|
244
|
+
model: {
|
|
245
|
+
execute,
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
const result = create_page(ui, { name: "Second Page" });
|
|
251
|
+
expect(stopEditing).toHaveBeenCalledWith(false);
|
|
252
|
+
expect(ui.createPage).toHaveBeenCalledWith("Second Page", "page-2");
|
|
253
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
254
|
+
expect(stopEditing.mock.invocationCallOrder[0]).toBeLessThan(execute.mock.invocationCallOrder[0]);
|
|
255
|
+
expect(result).toMatchObject({
|
|
256
|
+
id: "page-2",
|
|
257
|
+
name: "Second Page",
|
|
258
|
+
is_current: false,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
if (originalChangePage === undefined) {
|
|
263
|
+
delete globalThis.ChangePage;
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
globalThis.ChangePage =
|
|
267
|
+
originalChangePage;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
it("renames off-page targets without switching the visible page", async () => {
|
|
272
|
+
const { rename_page } = await loadDrawioTools();
|
|
273
|
+
const originalRenamePage = globalThis
|
|
274
|
+
.RenamePage;
|
|
275
|
+
globalThis.RenamePage =
|
|
276
|
+
function FakeRenamePage(_ui, page, nextName) {
|
|
277
|
+
this.page = page;
|
|
278
|
+
this.previous = nextName;
|
|
279
|
+
this.execute = () => {
|
|
280
|
+
this.page.setName(this.previous);
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
try {
|
|
284
|
+
const currentPage = {
|
|
285
|
+
getId: () => "page-1",
|
|
286
|
+
getName: () => "Visible Page",
|
|
287
|
+
};
|
|
288
|
+
let targetName = "Target Page";
|
|
289
|
+
const targetPage = {
|
|
290
|
+
getId: () => "page-2",
|
|
291
|
+
getName: () => targetName,
|
|
292
|
+
setName: jest.fn((nextName) => {
|
|
293
|
+
targetName = nextName;
|
|
294
|
+
}),
|
|
295
|
+
};
|
|
296
|
+
const execute = jest.fn((command) => {
|
|
297
|
+
command.execute();
|
|
298
|
+
});
|
|
299
|
+
const ui = {
|
|
300
|
+
currentPage,
|
|
301
|
+
pages: [currentPage, targetPage],
|
|
302
|
+
selectPage: jest.fn(),
|
|
303
|
+
editor: {
|
|
304
|
+
graph: {
|
|
305
|
+
model: {
|
|
306
|
+
execute,
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
const result = rename_page(ui, {
|
|
312
|
+
page: { id: "page-2" },
|
|
313
|
+
name: "Renamed Target",
|
|
314
|
+
});
|
|
315
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
316
|
+
expect(targetPage.setName).toHaveBeenCalledWith("Renamed Target");
|
|
317
|
+
expect(ui.selectPage).not.toHaveBeenCalled();
|
|
318
|
+
expect(ui.currentPage).toBe(currentPage);
|
|
319
|
+
expect(result).toEqual({
|
|
320
|
+
index: 1,
|
|
321
|
+
id: "page-2",
|
|
322
|
+
name: "Renamed Target",
|
|
323
|
+
is_current: false,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
if (originalRenamePage === undefined) {
|
|
328
|
+
delete globalThis.RenamePage;
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
globalThis.RenamePage =
|
|
332
|
+
originalRenamePage;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
it("copies a target page and restores the visible page", async () => {
|
|
337
|
+
const { copy_page } = await loadDrawioTools();
|
|
338
|
+
const currentPage = {
|
|
339
|
+
getId: () => "page-1",
|
|
340
|
+
getName: () => "Visible Page",
|
|
341
|
+
};
|
|
342
|
+
const sourcePage = {
|
|
343
|
+
getId: () => "page-2",
|
|
344
|
+
getName: () => "Source Page",
|
|
345
|
+
};
|
|
346
|
+
const trailingPage = {
|
|
347
|
+
getId: () => "page-4",
|
|
348
|
+
getName: () => "Trailing Page",
|
|
349
|
+
};
|
|
350
|
+
let copiedName = "Source Page Copy";
|
|
351
|
+
const copiedPage = {
|
|
352
|
+
getId: () => "page-3",
|
|
353
|
+
getName: () => copiedName,
|
|
354
|
+
setName: jest.fn((nextName) => {
|
|
355
|
+
copiedName = nextName;
|
|
356
|
+
}),
|
|
357
|
+
};
|
|
358
|
+
const ui = {
|
|
359
|
+
currentPage,
|
|
360
|
+
pages: [currentPage, sourcePage, trailingPage],
|
|
361
|
+
duplicatePage: jest.fn((page, name) => {
|
|
362
|
+
copiedName = name ?? copiedName;
|
|
363
|
+
const sourceIndex = ui.pages.indexOf(page);
|
|
364
|
+
ui.pages.splice(sourceIndex + 1, 0, copiedPage);
|
|
365
|
+
ui.currentPage = copiedPage;
|
|
366
|
+
return copiedPage;
|
|
367
|
+
}),
|
|
368
|
+
movePage: jest.fn((oldIndex, newIndex) => {
|
|
369
|
+
const [page] = ui.pages.splice(oldIndex, 1);
|
|
370
|
+
ui.pages.splice(newIndex, 0, page);
|
|
371
|
+
}),
|
|
372
|
+
selectPage: jest.fn((page) => {
|
|
373
|
+
ui.currentPage = page;
|
|
374
|
+
}),
|
|
375
|
+
};
|
|
376
|
+
const result = copy_page(ui, {
|
|
377
|
+
page: { id: "page-2" },
|
|
378
|
+
name: "Copied Source Page",
|
|
379
|
+
});
|
|
380
|
+
expect(ui.duplicatePage).toHaveBeenCalledWith(sourcePage, "Copied Source Page");
|
|
381
|
+
expect(ui.movePage).toHaveBeenCalledWith(2, 3);
|
|
382
|
+
expect(ui.selectPage).toHaveBeenCalledWith(currentPage);
|
|
383
|
+
expect(ui.currentPage).toBe(currentPage);
|
|
384
|
+
expect(ui.pages.map((page) => page.getId())).toEqual([
|
|
385
|
+
"page-1",
|
|
386
|
+
"page-2",
|
|
387
|
+
"page-4",
|
|
388
|
+
"page-3",
|
|
389
|
+
]);
|
|
390
|
+
expect(result).toEqual({
|
|
391
|
+
index: 3,
|
|
392
|
+
id: "page-3",
|
|
393
|
+
name: "Copied Source Page",
|
|
394
|
+
is_current: false,
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
it("copies a target page to the end through the low-level move command", async () => {
|
|
398
|
+
const { copy_page } = await loadDrawioTools();
|
|
399
|
+
const originalMovePage = globalThis.MovePage;
|
|
400
|
+
globalThis.MovePage = function FakeMovePage(ui, oldIndex, newIndex) {
|
|
401
|
+
this.ui = ui;
|
|
402
|
+
this.oldIndex = oldIndex;
|
|
403
|
+
this.newIndex = newIndex;
|
|
404
|
+
this.execute = () => {
|
|
405
|
+
const [page] = this.ui.pages.splice(this.oldIndex, 1);
|
|
406
|
+
this.ui.pages.splice(this.newIndex, 0, page);
|
|
407
|
+
};
|
|
408
|
+
};
|
|
409
|
+
try {
|
|
410
|
+
const currentPage = {
|
|
411
|
+
getId: () => "page-1",
|
|
412
|
+
getName: () => "Visible Page",
|
|
413
|
+
};
|
|
414
|
+
const sourcePage = {
|
|
415
|
+
getId: () => "page-2",
|
|
416
|
+
getName: () => "Source Page",
|
|
417
|
+
};
|
|
418
|
+
const trailingPage = {
|
|
419
|
+
getId: () => "page-4",
|
|
420
|
+
getName: () => "Trailing Page",
|
|
421
|
+
};
|
|
422
|
+
const copiedPage = {
|
|
423
|
+
getId: () => "page-3",
|
|
424
|
+
getName: () => "Copied Source Page",
|
|
425
|
+
};
|
|
426
|
+
const execute = jest.fn((command) => {
|
|
427
|
+
command.execute();
|
|
428
|
+
});
|
|
429
|
+
const ui = {
|
|
430
|
+
currentPage,
|
|
431
|
+
pages: [currentPage, sourcePage, trailingPage],
|
|
432
|
+
duplicatePage: jest.fn((page) => {
|
|
433
|
+
const sourceIndex = ui.pages.indexOf(page);
|
|
434
|
+
ui.pages.splice(sourceIndex + 1, 0, copiedPage);
|
|
435
|
+
ui.currentPage = copiedPage;
|
|
436
|
+
return copiedPage;
|
|
437
|
+
}),
|
|
438
|
+
selectPage: jest.fn((page) => {
|
|
439
|
+
ui.currentPage = page;
|
|
440
|
+
}),
|
|
441
|
+
editor: {
|
|
442
|
+
graph: {
|
|
443
|
+
model: {
|
|
444
|
+
execute,
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
const result = copy_page(ui, {
|
|
450
|
+
page: { id: "page-2" },
|
|
451
|
+
name: "Copied Source Page",
|
|
452
|
+
});
|
|
453
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
454
|
+
expect(ui.pages.map((page) => page.getId())).toEqual([
|
|
455
|
+
"page-1",
|
|
456
|
+
"page-2",
|
|
457
|
+
"page-4",
|
|
458
|
+
"page-3",
|
|
459
|
+
]);
|
|
460
|
+
expect(ui.currentPage).toBe(currentPage);
|
|
461
|
+
expect(result.index).toBe(3);
|
|
462
|
+
}
|
|
463
|
+
finally {
|
|
464
|
+
if (originalMovePage === undefined) {
|
|
465
|
+
delete globalThis.MovePage;
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
globalThis.MovePage = originalMovePage;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
it("runs background-safe tools on off-page models without selecting the page", async () => {
|
|
473
|
+
await activateDocument();
|
|
474
|
+
const toolDefinitions = await loadToolDefinitions();
|
|
475
|
+
const registry = new Map(toolDefinitions.map((definition) => [definition.name, definition]));
|
|
476
|
+
const handler = registry.get("add-rectangle")?.handler;
|
|
477
|
+
expect(handler).toBeDefined();
|
|
478
|
+
const originalWindow = globalThis.window;
|
|
479
|
+
const originalDocument = globalThis.document;
|
|
480
|
+
const body = {
|
|
481
|
+
appendChild: jest.fn((node) => {
|
|
482
|
+
node.parentNode = body;
|
|
483
|
+
}),
|
|
484
|
+
removeChild: jest.fn((node) => {
|
|
485
|
+
node.parentNode = null;
|
|
486
|
+
}),
|
|
487
|
+
};
|
|
488
|
+
globalThis.window = {
|
|
489
|
+
mxEvent: {
|
|
490
|
+
CHANGE: "change",
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
globalThis.document = { body };
|
|
494
|
+
const currentPage = {
|
|
495
|
+
getId: () => "page-1",
|
|
496
|
+
getName: () => "Current",
|
|
497
|
+
root: { id: "current-root" },
|
|
498
|
+
viewState: { defaultParent: "current-layer" },
|
|
499
|
+
};
|
|
500
|
+
const targetPage = {
|
|
501
|
+
getId: () => "page-2",
|
|
502
|
+
getName: () => "Target",
|
|
503
|
+
root: { id: "target-root" },
|
|
504
|
+
viewState: {
|
|
505
|
+
defaultParent: "target-layer",
|
|
506
|
+
background: "#ffffff",
|
|
507
|
+
},
|
|
508
|
+
graphModelNode: { cached: true },
|
|
509
|
+
setDiagramModified: jest.fn(),
|
|
510
|
+
};
|
|
511
|
+
const backgroundListeners = new Set();
|
|
512
|
+
const tempModel = {
|
|
513
|
+
beginUpdate: jest.fn(),
|
|
514
|
+
endUpdate: jest.fn(),
|
|
515
|
+
getCell: jest.fn(),
|
|
516
|
+
addListener: jest.fn((_eventName, listener) => {
|
|
517
|
+
backgroundListeners.add(listener);
|
|
518
|
+
}),
|
|
519
|
+
removeListener: jest.fn((listener) => {
|
|
520
|
+
backgroundListeners.delete(listener);
|
|
521
|
+
}),
|
|
522
|
+
setRoot: jest.fn(),
|
|
523
|
+
};
|
|
524
|
+
const tempGraph = {
|
|
525
|
+
container: {},
|
|
526
|
+
getModel: jest.fn(() => tempModel),
|
|
527
|
+
getDefaultParent: jest.fn(() => "target-layer"),
|
|
528
|
+
insertVertex: jest.fn(() => {
|
|
529
|
+
for (const listener of backgroundListeners) {
|
|
530
|
+
listener();
|
|
531
|
+
}
|
|
532
|
+
return { id: "rect-1" };
|
|
533
|
+
}),
|
|
534
|
+
setViewState: jest.fn(),
|
|
535
|
+
setAdaptiveColors: jest.fn(),
|
|
536
|
+
setBackgroundImage: jest.fn(),
|
|
537
|
+
destroy: jest.fn(),
|
|
538
|
+
};
|
|
539
|
+
const fileChanged = jest.fn();
|
|
540
|
+
const ui = {
|
|
541
|
+
pages: [currentPage, targetPage],
|
|
542
|
+
currentPage,
|
|
543
|
+
selectPage: jest.fn(),
|
|
544
|
+
updatePageRoot: jest.fn((page) => page),
|
|
545
|
+
createTemporaryGraph: jest.fn(() => tempGraph),
|
|
546
|
+
getCurrentFile: jest.fn(() => ({
|
|
547
|
+
fileChanged,
|
|
548
|
+
})),
|
|
549
|
+
editor: {
|
|
550
|
+
graph: {
|
|
551
|
+
getStylesheet: jest.fn(() => ({ name: "live-stylesheet" })),
|
|
552
|
+
getModel: jest.fn(() => ({
|
|
553
|
+
root: currentPage.root,
|
|
554
|
+
})),
|
|
555
|
+
getViewState: jest.fn(() => currentPage.viewState),
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
};
|
|
559
|
+
try {
|
|
560
|
+
const result = handler?.(ui, {
|
|
561
|
+
target_document: { id: "doc-1" },
|
|
562
|
+
target_page: { id: "page-2" },
|
|
563
|
+
text: "Background rectangle",
|
|
564
|
+
});
|
|
565
|
+
expect(result.id).toBe("rect-1");
|
|
566
|
+
expect(ui.selectPage).not.toHaveBeenCalled();
|
|
567
|
+
expect(ui.updatePageRoot).toHaveBeenCalledWith(targetPage);
|
|
568
|
+
expect(tempGraph.setViewState).toHaveBeenCalledWith(targetPage.viewState);
|
|
569
|
+
expect(targetPage.needsUpdate).toBe(true);
|
|
570
|
+
expect(targetPage.graphModelNode).toBeNull();
|
|
571
|
+
expect(targetPage.setDiagramModified).toHaveBeenCalledWith(true);
|
|
572
|
+
expect(fileChanged).toHaveBeenCalledTimes(1);
|
|
573
|
+
expect(body.appendChild).toHaveBeenCalledTimes(1);
|
|
574
|
+
expect(tempGraph.destroy).toHaveBeenCalledTimes(1);
|
|
575
|
+
expect(tempModel.removeListener).toHaveBeenCalledTimes(1);
|
|
576
|
+
}
|
|
577
|
+
finally {
|
|
578
|
+
if (originalWindow === undefined) {
|
|
579
|
+
delete globalThis.window;
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
globalThis.window = originalWindow;
|
|
583
|
+
}
|
|
584
|
+
if (originalDocument === undefined) {
|
|
585
|
+
delete globalThis.document;
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
globalThis.document = originalDocument;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
it("falls back to visible-page execution when background graph support is unavailable", async () => {
|
|
593
|
+
await activateDocument();
|
|
594
|
+
const toolDefinitions = await loadToolDefinitions();
|
|
595
|
+
const registry = new Map(toolDefinitions.map((definition) => [definition.name, definition]));
|
|
596
|
+
const handler = registry.get("add-rectangle")?.handler;
|
|
597
|
+
expect(handler).toBeDefined();
|
|
598
|
+
const liveModel = {
|
|
599
|
+
beginUpdate: jest.fn(),
|
|
600
|
+
endUpdate: jest.fn(),
|
|
601
|
+
getCell: jest.fn(),
|
|
602
|
+
};
|
|
603
|
+
const liveGraph = {
|
|
604
|
+
getModel: jest.fn(() => liveModel),
|
|
605
|
+
getDefaultParent: jest.fn(() => "visible-layer"),
|
|
606
|
+
insertVertex: jest.fn(() => ({ id: "visible-rect" })),
|
|
607
|
+
};
|
|
608
|
+
const currentPage = {
|
|
609
|
+
getId: () => "page-1",
|
|
610
|
+
getName: () => "Current",
|
|
611
|
+
};
|
|
612
|
+
const targetPage = {
|
|
613
|
+
getId: () => "page-2",
|
|
614
|
+
getName: () => "Target",
|
|
615
|
+
};
|
|
616
|
+
const ui = {
|
|
617
|
+
pages: [currentPage, targetPage],
|
|
618
|
+
currentPage,
|
|
619
|
+
selectPage: jest.fn(),
|
|
620
|
+
editor: {
|
|
621
|
+
graph: liveGraph,
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
const result = handler?.(ui, {
|
|
625
|
+
target_document: { id: "doc-1" },
|
|
626
|
+
target_page: { id: "page-2" },
|
|
627
|
+
text: "Visible fallback",
|
|
628
|
+
});
|
|
629
|
+
expect(result.id).toBe("visible-rect");
|
|
630
|
+
expect(ui.selectPage).toHaveBeenCalledWith(targetPage);
|
|
631
|
+
});
|
|
632
|
+
it("cleans up temporary graph resources when background setup fails", async () => {
|
|
633
|
+
await activateDocument();
|
|
634
|
+
const toolDefinitions = await loadToolDefinitions();
|
|
635
|
+
const registry = new Map(toolDefinitions.map((definition) => [definition.name, definition]));
|
|
636
|
+
const handler = registry.get("add-rectangle")?.handler;
|
|
637
|
+
expect(handler).toBeDefined();
|
|
638
|
+
const originalWindow = globalThis.window;
|
|
639
|
+
const originalDocument = globalThis.document;
|
|
640
|
+
const body = {
|
|
641
|
+
appendChild: jest.fn((node) => {
|
|
642
|
+
node.parentNode = body;
|
|
643
|
+
}),
|
|
644
|
+
removeChild: jest.fn((node) => {
|
|
645
|
+
node.parentNode = null;
|
|
646
|
+
}),
|
|
647
|
+
};
|
|
648
|
+
globalThis.window = {
|
|
649
|
+
mxEvent: {
|
|
650
|
+
CHANGE: "change",
|
|
651
|
+
},
|
|
652
|
+
};
|
|
653
|
+
globalThis.document = { body };
|
|
654
|
+
const currentPage = {
|
|
655
|
+
getId: () => "page-1",
|
|
656
|
+
getName: () => "Current",
|
|
657
|
+
root: { id: "current-root" },
|
|
658
|
+
viewState: { defaultParent: "current-layer" },
|
|
659
|
+
};
|
|
660
|
+
const targetPage = {
|
|
661
|
+
getId: () => "page-2",
|
|
662
|
+
getName: () => "Target",
|
|
663
|
+
root: { id: "target-root" },
|
|
664
|
+
viewState: { defaultParent: "target-layer" },
|
|
665
|
+
};
|
|
666
|
+
const tempModel = {
|
|
667
|
+
addListener: jest.fn(),
|
|
668
|
+
removeListener: jest.fn(),
|
|
669
|
+
setRoot: jest.fn(),
|
|
670
|
+
getCell: jest.fn(),
|
|
671
|
+
beginUpdate: jest.fn(),
|
|
672
|
+
endUpdate: jest.fn(),
|
|
673
|
+
};
|
|
674
|
+
const tempGraph = {
|
|
675
|
+
container: {},
|
|
676
|
+
getModel: jest.fn(() => tempModel),
|
|
677
|
+
setViewState: jest.fn(() => {
|
|
678
|
+
throw new Error("boom");
|
|
679
|
+
}),
|
|
680
|
+
destroy: jest.fn(),
|
|
681
|
+
};
|
|
682
|
+
const ui = {
|
|
683
|
+
pages: [currentPage, targetPage],
|
|
684
|
+
currentPage,
|
|
685
|
+
selectPage: jest.fn(),
|
|
686
|
+
updatePageRoot: jest.fn((page) => page),
|
|
687
|
+
createTemporaryGraph: jest.fn(() => tempGraph),
|
|
688
|
+
editor: {
|
|
689
|
+
graph: {
|
|
690
|
+
getStylesheet: jest.fn(() => ({ name: "live-stylesheet" })),
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
try {
|
|
695
|
+
expect(() => handler?.(ui, {
|
|
696
|
+
target_document: { id: "doc-1" },
|
|
697
|
+
target_page: { id: "page-2" },
|
|
698
|
+
text: "Broken background setup",
|
|
699
|
+
})).toThrow("boom");
|
|
700
|
+
expect(tempModel.removeListener).toHaveBeenCalledTimes(1);
|
|
701
|
+
expect(tempGraph.destroy).toHaveBeenCalledTimes(1);
|
|
702
|
+
expect(body.removeChild).toHaveBeenCalledTimes(1);
|
|
703
|
+
}
|
|
704
|
+
finally {
|
|
705
|
+
if (originalWindow === undefined) {
|
|
706
|
+
delete globalThis.window;
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
globalThis.window = originalWindow;
|
|
710
|
+
}
|
|
711
|
+
if (originalDocument === undefined) {
|
|
712
|
+
delete globalThis.document;
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
globalThis.document = originalDocument;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
it("keeps UI-bound tools on the visible-page execution path", async () => {
|
|
720
|
+
await activateDocument();
|
|
721
|
+
const toolDefinitions = await loadToolDefinitions();
|
|
722
|
+
const registry = new Map(toolDefinitions.map((definition) => [definition.name, definition]));
|
|
723
|
+
const handler = registry.get("get-selected-cell")?.handler;
|
|
724
|
+
expect(handler).toBeDefined();
|
|
725
|
+
const selection = { id: "selected-cell" };
|
|
726
|
+
const currentPage = {
|
|
727
|
+
getId: () => "page-1",
|
|
728
|
+
getName: () => "Current",
|
|
729
|
+
};
|
|
730
|
+
const targetPage = {
|
|
731
|
+
getId: () => "page-2",
|
|
732
|
+
getName: () => "Target",
|
|
733
|
+
};
|
|
734
|
+
const ui = {
|
|
735
|
+
pages: [currentPage, targetPage],
|
|
736
|
+
currentPage,
|
|
737
|
+
selectPage: jest.fn(),
|
|
738
|
+
editor: {
|
|
739
|
+
graph: {
|
|
740
|
+
getSelectionCell: jest.fn(() => selection),
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
expect(handler?.(ui, {
|
|
745
|
+
target_document: { id: "doc-1" },
|
|
746
|
+
target_page: { id: "page-2" },
|
|
747
|
+
})).toBe(selection);
|
|
748
|
+
expect(ui.selectPage).toHaveBeenCalledWith(targetPage);
|
|
749
|
+
});
|
|
750
|
+
it("fails create-page when the environment cannot rename pages", async () => {
|
|
751
|
+
const { create_page } = await loadDrawioTools();
|
|
752
|
+
const insertPage = jest.fn();
|
|
753
|
+
const ui = {
|
|
754
|
+
currentPage: {
|
|
755
|
+
getId: () => "page-1",
|
|
756
|
+
getName: () => "First",
|
|
757
|
+
},
|
|
758
|
+
pages: [
|
|
759
|
+
{
|
|
760
|
+
getId: () => "page-1",
|
|
761
|
+
getName: () => "First",
|
|
762
|
+
},
|
|
763
|
+
],
|
|
764
|
+
insertPage,
|
|
765
|
+
};
|
|
766
|
+
expect(() => create_page(ui, { name: "Named Page" })).toThrow("Draw.io page renaming is not supported in this version; cannot create a named page");
|
|
767
|
+
expect(insertPage).not.toHaveBeenCalled();
|
|
768
|
+
});
|
|
769
|
+
it("fails import-diagram new-page when the environment cannot rename imported pages", async () => {
|
|
770
|
+
const { import_diagram } = await loadDrawioTools();
|
|
771
|
+
const insertPage = jest.fn();
|
|
772
|
+
const originalWindow = globalThis.window;
|
|
773
|
+
const fakeDocument = {
|
|
774
|
+
documentElement: {
|
|
775
|
+
nodeName: "mxGraphModel",
|
|
776
|
+
},
|
|
777
|
+
getElementsByTagName: () => [],
|
|
778
|
+
};
|
|
779
|
+
globalThis.window = {
|
|
780
|
+
mxUtils: {
|
|
781
|
+
parseXml: () => fakeDocument,
|
|
782
|
+
},
|
|
783
|
+
};
|
|
784
|
+
try {
|
|
785
|
+
const result = import_diagram({
|
|
786
|
+
currentPage: {
|
|
787
|
+
getId: () => "page-1",
|
|
788
|
+
getName: () => "First",
|
|
789
|
+
},
|
|
790
|
+
pages: [
|
|
791
|
+
{
|
|
792
|
+
getId: () => "page-1",
|
|
793
|
+
getName: () => "First",
|
|
794
|
+
},
|
|
795
|
+
],
|
|
796
|
+
insertPage,
|
|
797
|
+
editor: {
|
|
798
|
+
graph: {
|
|
799
|
+
getModel: () => ({}),
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
}, {
|
|
803
|
+
data: "<mxGraphModel><root/></mxGraphModel>",
|
|
804
|
+
format: "xml",
|
|
805
|
+
mode: "new-page",
|
|
806
|
+
filename: "Imported.drawio",
|
|
807
|
+
});
|
|
808
|
+
expect(result).toEqual({
|
|
809
|
+
success: false,
|
|
810
|
+
message: "Draw.io page renaming is not supported in this version; cannot create a named imported page",
|
|
811
|
+
});
|
|
812
|
+
expect(insertPage).not.toHaveBeenCalled();
|
|
813
|
+
}
|
|
814
|
+
finally {
|
|
815
|
+
if (originalWindow === undefined) {
|
|
816
|
+
delete globalThis.window;
|
|
817
|
+
}
|
|
818
|
+
else {
|
|
819
|
+
globalThis.window = originalWindow;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
});
|