Compare commits

...
Author SHA1 Message Date
dayuan.jiang 6ba53adbc6 docs(mcp): mention the xml source of load_diagram in the tool table 2026-10-11 10:03:36 +09:00
caoxiaole07andcaoxiaole07 8d690503d4 feat(mcp): load_diagram accepts inline 'xml' content as an alternative to 'path' (#946)
Agents frequently hold .drawio content in memory (another tool's output,
a repository read, an API response) and previously had to write it to a
temporary file just so load_diagram could read it back. Add a mutually
exclusive 'xml' argument that goes through the same parser as the 'path'
branch, so both plain XML and draw.io's compressed save format work.

Edit-gate semantics by source:
- 'path': unchanged — the model has not seen the content, one
  get_diagram round-trip is still required before editing.
- plain 'xml': the model supplied the exact content (same rationale as
  create_new_diagram), so it is recorded as seen and can be edited
  immediately.
- compressed 'xml': the session stores the decompressed form, which the
  model cannot derive from the compressed input — the gate is kept.

Argument validation (mutual exclusion / presence) fires before the
session check so callers get useful errors regardless of session state.
parseDrawioFileContent now reports whether any page was decompressed,
which drives the gate decision. The diagram-workflow prompt is updated
to document the new argument.

Co-authored-by: caoxiaole07 <[email protected]>
2026-10-11 10:03:13 +09:00
renovate[bot] 4b4f36e374 chore(deps): update dependency @biomejs/biome to v2.5.15 (#931)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 08:33:56 +09:00
Dayuan Jiang a9091e96f1 ci: remind and close issues that wait for the reporter (#977)
Issues labelled "wait for confrim" get a reminder and the "no response"
label after 15 days without activity, and are closed as not planned after
15 more. A reply from the reporter removes both labels.
2026-10-11 08:02:28 +09:00
Dayuan Jiang 213b672e92 test(e2e): deselect by clicking a corner away from the shape (#975)
The test clicked the canvas at (10, 10) to deselect. When the click on the
shape before it needed retries, Playwright scrolled the shape into the
container's top-left corner, so the next click landed on the shape and the
selection stayed (seen on the main run of cd5352c and on #973). The new
clickEmptyCanvas helper clicks the corner farthest from the shape.
Reproduced locally by scrolling the shape into the corner: the old click
keeps the selection, the helper clears it.
2026-10-10 23:53:30 +09:00
10 changed files with 291 additions and 81 deletions
@@ -0,0 +1,55 @@
# Issues labelled "wait for confrim" are waiting for the reporter. After 15
# days without activity the issue gets a reminder and the "no response"
# label; after 15 more days it is closed. A reply from the reporter removes
# both labels, so the issue shows up as needing the maintainer again.
name: Wait for confirmation
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
issue_comment:
types: [created]
permissions:
issues: write
jobs:
remind-and-close:
if: github.event_name != 'issue_comment'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
only-issue-labels: "wait for confrim"
days-before-issue-stale: 15
days-before-issue-close: 15
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: "no response"
stale-issue-message: >-
Still there? We asked for more information 15 days ago. A reply
would help; if we hear nothing in another 15 days this issue will
be closed. It can be reopened any time.
close-issue-message: >-
No reply in 30 days, so this issue is closed for now. If the
problem is still there, reply or reopen it.
close-issue-reason: not_planned
remove-stale-when-updated: true
reporter-replied:
if: >-
github.event_name == 'issue_comment' &&
contains(github.event.issue.labels.*.name, 'wait for confrim') &&
github.event.comment.user.type != 'Bot' &&
!contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
steps:
- name: The reporter answered, so the issue is the maintainer's again
run: >-
gh issue edit "$NUMBER" --repo "$REPO"
--remove-label "wait for confrim" --remove-label "no response"
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
+36 -36
View File
@@ -75,7 +75,7 @@
},
"devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.5.7",
"@biomejs/biome": "2.5.15",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
@@ -2093,9 +2093,9 @@
}
},
"node_modules/@biomejs/biome": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.7.tgz",
"integrity": "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz",
"integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@@ -2109,20 +2109,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.5.7",
"@biomejs/cli-darwin-x64": "2.5.7",
"@biomejs/cli-linux-arm64": "2.5.7",
"@biomejs/cli-linux-arm64-musl": "2.5.7",
"@biomejs/cli-linux-x64": "2.5.7",
"@biomejs/cli-linux-x64-musl": "2.5.7",
"@biomejs/cli-win32-arm64": "2.5.7",
"@biomejs/cli-win32-x64": "2.5.7"
"@biomejs/cli-darwin-arm64": "2.5.15",
"@biomejs/cli-darwin-x64": "2.5.15",
"@biomejs/cli-linux-arm64": "2.5.15",
"@biomejs/cli-linux-arm64-musl": "2.5.15",
"@biomejs/cli-linux-x64": "2.5.15",
"@biomejs/cli-linux-x64-musl": "2.5.15",
"@biomejs/cli-win32-arm64": "2.5.15",
"@biomejs/cli-win32-x64": "2.5.15"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.7.tgz",
"integrity": "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz",
"integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==",
"cpu": [
"arm64"
],
@@ -2137,9 +2137,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.7.tgz",
"integrity": "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz",
"integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==",
"cpu": [
"x64"
],
@@ -2154,9 +2154,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.7.tgz",
"integrity": "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz",
"integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==",
"cpu": [
"arm64"
],
@@ -2174,9 +2174,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.7.tgz",
"integrity": "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz",
"integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==",
"cpu": [
"arm64"
],
@@ -2194,9 +2194,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.7.tgz",
"integrity": "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz",
"integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==",
"cpu": [
"x64"
],
@@ -2214,9 +2214,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.7.tgz",
"integrity": "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz",
"integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==",
"cpu": [
"x64"
],
@@ -2234,9 +2234,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.7.tgz",
"integrity": "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz",
"integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==",
"cpu": [
"arm64"
],
@@ -2251,9 +2251,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.7.tgz",
"integrity": "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz",
"integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==",
"cpu": [
"x64"
],
+1 -1
View File
@@ -110,7 +110,7 @@
},
"devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.5.7",
"@biomejs/biome": "2.5.15",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
+1 -1
View File
@@ -123,7 +123,7 @@ Use the standard MCP configuration with:
| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted |
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` |
| `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough |
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) |
| `load_diagram` | Load a `.drawio` file into the session, from a `path` on disk or from its `xml` content (handles compressed files) |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing |
| `get_diagram` | Get the current diagram XML, including your edits in the browser |
| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram |
+1 -1
View File
@@ -18,7 +18,7 @@ import {
export const DRAWING_GUIDE = `# Draw.io drawing guide
## Workflow
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself, or takes the file's content as its 'xml' argument when you already have it in hand). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes.
- Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message.
- Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4).
+86 -35
View File
@@ -455,21 +455,54 @@ registerWriteTool(
{
title: "Load .drawio file",
description:
"Load a .drawio file from disk into the current session, REPLACING the entire diagram (all pages). " +
"The server reads the file directly — you do NOT need to read the file yourself or pass its XML through create_new_diagram. " +
"Handles both plain-XML and draw.io's compressed save format.\n\n" +
"After loading, call get_diagram before edit_diagram — you haven't seen the file's cell IDs or structure yet.",
"Load a .drawio diagram into the current session, REPLACING the entire diagram (all pages). " +
"Provide ONE of two mutually exclusive sources: 'path' (the server reads the file from disk — you do NOT need to read the file yourself or pass its XML through create_new_diagram) " +
"or 'xml' (the raw file content you already have — from another tool, a repository read, or an API response — so no temporary file needs to be written first). " +
"Both accept plain XML and draw.io's compressed save format.\n\n" +
"After loading from 'path' (or from compressed 'xml'), call get_diagram before edit_diagram — you haven't seen the file's cell IDs yet. " +
"Plain-XML 'xml' content you supplied yourself is already known and can be edited immediately.",
inputSchema: {
path: z
.string()
.optional()
.describe(
"Absolute path to the .drawio file to load (e.g. /Users/me/diagram.drawio or ~/diagram.drawio). Relative paths resolve against the MCP server's working directory, which is often not your project.",
"Path to the .drawio file to load (e.g. /Users/me/diagram.drawio or ~/diagram.drawio). Relative paths resolve against the MCP server's working directory, which is often not your project. Mutually exclusive with 'xml'.",
),
xml: z
.string()
.optional()
.describe(
"Raw .drawio file content: a plain <mxfile>/<mxGraphModel>, or draw.io's compressed save format. Use when the content is already in hand (another tool's output, a repository read, an API response). Mutually exclusive with 'path'.",
),
},
annotations: { openWorldHint: false },
},
async ({ path }) => {
async ({ path, xml: inlineXml }) => {
try {
// Argument validation comes before the session check: a bad
// argument is a caller error and should be reported as such.
if (path !== undefined && inlineXml !== undefined) {
return {
content: [
{
type: "text",
text: "Error: Provide either 'path' or 'xml', not both.",
},
],
isError: true,
}
}
if (path === undefined && inlineXml === undefined) {
return {
content: [
{
type: "text",
text: "Error: Provide either 'path' (a .drawio file to read) or 'xml' (the file's content).",
},
],
isError: true,
}
}
if (!currentSession) {
return {
content: [
@@ -482,29 +515,37 @@ registerWriteTool(
}
}
const fs = await import("node:fs/promises")
const nodePath = await import("node:path")
const absolutePath = nodePath.resolve(expandHome(path))
// Exactly one of path/xml is present (validated above).
let content = ""
let sourceLabel = ""
if (inlineXml !== undefined) {
content = inlineXml
sourceLabel = "inline XML"
} else if (path !== undefined) {
const fs = await import("node:fs/promises")
const nodePath = await import("node:path")
const absolutePath = nodePath.resolve(expandHome(path))
let content: string
try {
// A pipe or device could be read forever, and the other
// write tools wait for this one
if (!(await fs.stat(absolutePath)).isFile()) {
throw new Error("not a regular file")
}
content = await fs.readFile(absolutePath, "utf-8")
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return {
content: [
{
type: "text",
text: `Error: Cannot read file ${absolutePath}: ${msg}`,
},
],
isError: true,
try {
// A pipe or device could be read forever, and the other
// write tools wait for this one
if (!(await fs.stat(absolutePath)).isFile()) {
throw new Error("not a regular file")
}
content = await fs.readFile(absolutePath, "utf-8")
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return {
content: [
{
type: "text",
text: `Error: Cannot read file ${absolutePath}: ${msg}`,
},
],
isError: true,
}
}
sourceLabel = absolutePath
}
const loaded = parseDrawioFileContent(content)
@@ -517,7 +558,7 @@ registerWriteTool(
const xml = loaded.xml
log.info(
`Loading diagram from ${absolutePath} (${xml.length} chars)`,
`Loading diagram from ${sourceLabel} (${xml.length} chars)`,
)
// Save the user's current state before replacing (same flow as
@@ -537,10 +578,17 @@ registerWriteTool(
currentSession.xml = xml
currentSession.version++
setState(currentSession.id, xml)
// Deliberately NOT marking the loaded XML as seen: the model only
// supplied a path, so it doesn't know the file's cell IDs. The
// edit gate will require one get_diagram before edits.
currentSession.lastSeenXml = ""
// Edit-gate semantics by source:
// - 'path': the model only supplied a path, so it doesn't know
// the file's cell IDs — keep the gate (one get_diagram first).
// - plain 'xml': the model supplied the exact content, same
// rationale as create_new_diagram — record it as seen.
// - compressed 'xml': the session now holds the decompressed
// form, which the model cannot derive from the compressed
// input — keep the gate.
const markSeen =
inlineXml !== undefined && !loaded.hadCompressedPages
currentSession.lastSeenXml = markSeen ? xml : ""
addHistory(currentSession.id, xml, "")
@@ -551,13 +599,17 @@ registerWriteTool(
? `Pages (${pages.length}): ${pages.map((p) => `[${p.index}] id=${p.id} name="${p.name}" cells=${p.cellCount}`).join(" | ")}`
: "no pages parsed"
log.info(`Diagram loaded from file (${pageSummary})`)
log.info(`Diagram loaded (${pageSummary})`)
const gateHint = markSeen
? ""
: "\n\nCall get_diagram before edit_diagram — you haven't seen this file's cell IDs yet."
return {
content: [
{
type: "text",
text: `Diagram loaded from ${absolutePath}!\n\nThe diagram is now visible in your browser.\n\n${pageSummary}\n\nCall get_diagram before edit_diagram — you haven't seen this file's cell IDs yet.`,
text: `Diagram loaded from ${sourceLabel}!\n\nThe diagram is now visible in your browser.\n\n${pageSummary}${gateHint}`,
},
],
}
@@ -572,7 +624,6 @@ registerWriteTool(
}
},
)
// Tool: edit_diagram
registerWriteTool(
"edit_diagram",
+6 -2
View File
@@ -18,7 +18,7 @@ import {
import { getXmlSyntaxError } from "./xml-syntax.ts"
export type LoadResult =
| { ok: true; xml: string }
| { ok: true; xml: string; hadCompressedPages: boolean }
| { ok: false; error: string }
/**
@@ -101,5 +101,9 @@ export function parseDrawioFileContent(content: string): LoadResult {
decompressedAny = true
}
// Nothing changed — keep the file's own serialisation.
return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed }
return {
ok: true,
xml: decompressedAny ? serializeMxfile(doc) : trimmed,
hadCompressedPages: decompressedAny,
}
}
+37 -2
View File
@@ -54,7 +54,11 @@ describe("decompressPageContent", () => {
describe("parseDrawioFileContent", () => {
it("passes a plain-XML mxfile through unchanged", () => {
const r = parseDrawioFileContent(PLAIN_MXFILE)
expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE })
expect(r).toEqual({
ok: true,
xml: PLAIN_MXFILE,
hadCompressedPages: false,
})
})
it("wraps a bare mxGraphModel into a one-page mxfile", () => {
@@ -103,7 +107,11 @@ describe("parseDrawioFileContent", () => {
it("keeps empty pages as-is", () => {
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
const r = parseDrawioFileContent(withEmpty)
expect(r).toEqual({ ok: true, xml: withEmpty })
expect(r).toEqual({
ok: true,
xml: withEmpty,
hadCompressedPages: false,
})
})
it("rejects empty files", () => {
@@ -124,3 +132,30 @@ describe("parseDrawioFileContent", () => {
if (!r.ok) expect(r.error).toContain('"Broken"')
})
})
describe("hadCompressedPages", () => {
it("is false for a plain-XML mxfile", () => {
const r = parseDrawioFileContent(PLAIN_MXFILE)
expect(r.ok).toBe(true)
if (r.ok) expect(r.hadCompressedPages).toBe(false)
})
it("is false for a bare mxGraphModel", () => {
const r = parseDrawioFileContent(MODEL_XML)
expect(r.ok).toBe(true)
if (r.ok) expect(r.hadCompressedPages).toBe(false)
})
it("is true for a fully compressed mxfile", () => {
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
expect(r.ok).toBe(true)
if (r.ok) expect(r.hadCompressedPages).toBe(true)
})
it("is true for a mixed plain/compressed file", () => {
const mixed = `<mxfile><diagram id="a" name="Plain">${MODEL_XML}</diagram><diagram id="b" name="Squeezed">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
const r = parseDrawioFileContent(mixed)
expect(r.ok).toBe(true)
if (r.ok) expect(r.hadCompressedPages).toBe(true)
})
})
@@ -188,3 +188,51 @@ describe("MCP server wiring", () => {
expect(text.length).toBeLessThanOrEqual(15000)
})
})
describe("load_diagram dual-source arguments", () => {
it("advertises both optional 'path' and 'xml' sources", async () => {
const resp = await send("tools/list", {})
const load = resp.result.tools.find(
(t: { name: string }) => t.name === "load_diagram",
)
const props = load?.inputSchema?.properties ?? {}
expect(props.path).toBeTruthy()
expect(props.xml).toBeTruthy()
const required: string[] = load?.inputSchema?.required ?? []
expect(required).not.toContain("path")
expect(required).not.toContain("xml")
})
it("rejects passing both 'path' and 'xml'", async () => {
// Argument validation fires before the session check: no session
// exists in this harness, so a both-args call must report the
// mutual-exclusion error, not "No active session".
const resp = await send("tools/call", {
name: "load_diagram",
arguments: { path: "/tmp/x.drawio", xml: "<mxfile/>" },
})
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
expect(resp.result?.isError).toBe(true)
expect(resp.result?.content?.[0]?.text).toContain("not both")
})
it("rejects passing neither 'path' nor 'xml'", async () => {
const resp = await send("tools/call", {
name: "load_diagram",
arguments: {},
})
expect(resp.result?.isError).toBe(true)
expect(resp.result?.content?.[0]?.text).toContain("either 'path'")
})
it("accepts 'xml' alone as a source (fails only on the missing session)", async () => {
const resp = await send("tools/call", {
name: "load_diagram",
arguments: {
xml: '<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>',
},
})
expect(resp.result?.isError).toBe(true)
expect(resp.result?.content?.[0]?.text).toContain("No active session")
})
})
+20 -3
View File
@@ -108,6 +108,25 @@ async function boxOf(locator: Locator) {
return box
}
/**
* Click the canvas where no shape is, to deselect: the corner of the
* container farthest from the shape. A fixed spot near the top-left corner
* is not safe, as a click on a shape may scroll it into that corner.
*/
async function clickEmptyCanvas(page: Page, shape: Locator) {
const container = getIframeContent(page).locator(".geDiagramContainer")
const box = await boxOf(container)
const shapeBox = await boxOf(shape)
const shapeX = shapeBox.x + shapeBox.width / 2
const shapeY = shapeBox.y + shapeBox.height / 2
await container.click({
position: {
x: shapeX < box.x + box.width / 2 ? box.width - 20 : 20,
y: shapeY < box.y + box.height / 2 ? box.height - 20 : 20,
},
})
}
/** draw.io's own Undo button: draw.io sets "disabled" with nothing to undo */
function drawioUndo(page: Page) {
return getIframeContent(page).locator('.geSimpleMainMenu a[title="Undo"]')
@@ -4512,9 +4531,7 @@ test.describe("Edge cases", () => {
})
await sendMessage(page, "Make this red")
// Deselected while the diagram exports
await canvas
.locator(".geDiagramContainer")
.click({ position: { x: 10, y: 10 } })
await clickEmptyCanvas(page, shape)
await expect(chip).toHaveCount(0)
await page.evaluate(() => (window as any).__releaseExports())
await waitForCompleteCount(page, 2)