fix(mcp-server): keep both pages when get_diagram meets a page export, and more review fixes

Found by the PR review, each with a test that failed first:
- get_diagram during a page export returned the one-page projection on
  screen as the whole document (6 of 6 times when timed so). The preview
  page no longer answers a sync while a projection shows, and syncs after
  reloading, so the poll that restores the real document exports it.
- Exports are numbered on the server too: a late result of an export that
  timed out was saved as the next export's file.
- In Chrome, a new_xml with a syntax error counted the <parsererror>
  element as a second cell, so the web app rejected edits that auto-fix
  repairs ("must contain exactly one cell").
- hasCells missed single-quoted ids, so screenshot_diagram called such a
  diagram empty and auto-save never created its file.
- A literal \n directly under a <diagram> that has a model passed
  validation; only text-only pages are compressed data.
- A wrapped mxCell repeating its UserObject's id took the wrapper's place
  in edits, so delete and update left an empty or nested wrapper.
- Bare cells with a shape or edge id of "0" or "1" are rejected with a
  clear message instead of being renamed, which broke their edges.
- DRAWIO_DATA_DIR expands ~, which JSON configs pass on as it is.
This commit is contained in:
dayuan.jiang
2026-10-04 23:04:21 +09:00
parent 2bb69b58af
commit 504d2fa812
15 changed files with 189 additions and 26 deletions
@@ -124,3 +124,28 @@ describe("pages without a <root>", () => {
expect(errors[0]?.message).toContain("could not be decompressed")
})
})
describe("a wrapped mxCell with its wrapper's id", () => {
const doc = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="u" label="A" link="https://example.com"><mxCell id="u" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject></root></mxGraphModel></diagram></mxfile>`
it("deletes the whole wrapper", () => {
const { result, errors } = applyDiagramOperations(doc, [
{ operation: "delete", cell_id: "u" },
])
expect(errors).toEqual([])
expect(result).not.toContain("UserObject")
})
it("replaces the wrapper on update", () => {
const { result, errors } = applyDiagramOperations(doc, [
{
operation: "update",
cell_id: "u",
new_xml: `<UserObject id="u" label="B" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>`,
},
])
expect(errors).toEqual([])
expect(result.match(/<UserObject/g)).toHaveLength(1)
expect(result).toContain('label="B"')
})
})
@@ -258,9 +258,28 @@ describe("export requests", () => {
await postJson("/api/state", {
sessionId: id,
exportData: "data:image/png;base64,AAAA",
exportId: poll.exportId,
})
expect(getState(id)?.exportOptions).toBeUndefined()
})
it("ignores a late result of an export that already timed out", async () => {
const id = "mcp-export-late"
setState(id, "<mxfile>x</mxfile>")
requestExport(id, "png")
const first = JSON.parse(
(await request(`/api/state?sessionId=${id}`)).body,
)
// The server gave up on the first export and asked for the next
requestExport(id, "svg")
await postJson("/api/state", {
sessionId: id,
exportData: "data:image/png;base64,LATE",
exportId: first.exportId,
})
expect(getState(id)?.exportData).toBeUndefined()
expect(getState(id)?.exportFormat).toBe("svg")
})
})
describe("preview page", () => {
@@ -10,7 +10,7 @@ import {
utimesSync,
writeFileSync,
} from "node:fs"
import { tmpdir } from "node:os"
import { homedir, tmpdir } from "node:os"
import { join } from "node:path"
import { afterEach, describe, expect, it } from "vitest"
import { Autosaver, defaultDataDir } from "../src/persistence.ts"
@@ -88,4 +88,9 @@ describe("defaultDataDir", () => {
delete process.env.DRAWIO_DATA_DIR
expect(defaultDataDir()).toMatch(/\.next-ai-drawio$/)
})
it("expands ~, which JSON configs pass on as it is", () => {
process.env.DRAWIO_DATA_DIR = "~/drawio-saves"
expect(defaultDataDir()).toBe(join(homedir(), "drawio-saves"))
})
})
+32 -1
View File
@@ -10,7 +10,8 @@ beforeAll(() => {
installDomPolyfill()
})
import { wrapCellsInModel } from "../src/pages.ts"
import { prepareNewDiagram } from "../src/new-diagram.ts"
import { hasCells, wrapCellsInModel } from "../src/pages.ts"
import { validateAndFixXml } from "../src/xml-validation.ts"
const A = `<mxCell id="2" value="A" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
@@ -66,3 +67,33 @@ describe("wrapCellsInModel", () => {
)
})
})
describe("hasCells", () => {
it("counts cells written with single quotes", () => {
expect(hasCells(`<mxCell id='2' vertex='1' parent='1'/>`)).toBe(true)
expect(hasCells(`<mxCell id='0'/><mxCell id='1' parent='0'/>`)).toBe(
false,
)
})
})
describe("prepareNewDiagram", () => {
it("rejects a shape that uses a root cell id", () => {
// It would clash with the added root cell "1" and be renamed,
// which breaks the edges that point to it
const cells =
`<mxCell id="1" value="Start" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>` +
`<mxCell id="3" edge="1" parent="1" source="1" target="2"><mxGeometry relative="1" as="geometry"/></mxCell>`
const out = prepareNewDiagram(cells)
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.error).toMatch(/"0" and "1"/)
})
it("still accepts the root cells sent along with the shapes", () => {
const out = prepareNewDiagram(
`<mxCell id="0"/><mxCell id="1" parent="0"/>${A}`,
)
expect(out.ok).toBe(true)
})
})
@@ -308,3 +308,13 @@ describe("text between tags", () => {
).toBe(true)
})
})
describe("text directly under a page", () => {
it("fixes a literal \\n before the model of a page", () => {
const r = validateAndFixXml(
`<mxfile><diagram id="p" name="P">\\n${model(`<mxCell id="2" vertex="1" parent="1"/>`)}</diagram></mxfile>`,
)
expect(r.valid).toBe(true)
expect(r.fixed).not.toContain("\\n")
})
})