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
Dayuan Jiang d1ee1594f1 fix(chat): the AI reads and draws the page the user is viewing (#974)
* fix(chat): the AI reads and draws the page the user is viewing

The model always got the first page and display_diagram replaced the whole
file with one page, so drawing on page 3 of a multi-page file deleted the
other pages (#241), and edits went to page 1 while the user looked at
another page.

Now a turn records the page the user is viewing when the message is sent.
That page's model goes to the model (also as previousXml, on regenerate and
in edit_diagram error messages), selected shapes are sent on any page,
display_diagram and append_diagram replace only that page (a drawing that
brings several pages still replaces the file), edit_diagram targets it,
and the streaming preview draws on it. Compact cells and default styles
take the layer and the edges of that page, in the preview and in
editDiagram. The editor bridge replaces the current page in place when the
other pages are unchanged, so Ctrl+Z undoes an AI change on a multi-page
file in one step; when a full load is needed, the user's page is shown
again afterwards. Version summaries and the compare dialog count the page
the change was made on.

* test: read the failed edit's output without unsafe optional chaining
2026-10-10 23:30:51 +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)