feat(mcp-server): load .drawio.svg, change summary, XML reference by topic, preview options, truncation message (#982)

* feat(mcp-server): load .drawio.svg (Editable SVG) files with load_diagram

* feat(mcp-server): summarize the user's manual changes per cell in stale rejections and get_diagram

* feat(mcp-server): XML reference for tables, layers and groups via get_drawing_guide topic

* feat(mcp-server): DRAWIO_LANG, DRAWIO_UI and DRAWIO_DARK for the preview page

Three host-config environment variables fix the language, theme and
dark mode of the draw.io editor embedded in the preview page. The new
drawio-themes module holds the theme list and draw.io's locale names
(zh-hant becomes zh-tw); drawioEmbedParams() in http-server.ts builds
the variable tail of the iframe query and getHtmlPage fills the new
{{DRAWIO_PARAMS}} placeholder. Without the variables the page keeps
sending dark=auto as before. Both READMEs document the variables.

* feat(mcp-server): report XML cut off inside a cell and explain drawing in parts

create_new_diagram and add_page now detect bare-cell XML that ends inside an
unfinished mxCell (XML comments stripped first) and return an error with the
last 300 characters of the input, asking the model to resend from that cell or
continue with edit_diagram add operations. isMxCellXmlComplete moves from
lib/utils.ts into packages/mcp-server/src/new-diagram.ts and is re-exported
from lib/utils.ts for the web app. The drawing guide gains a "Large diagrams"
paragraph and the INSTRUCTIONS edit_diagram line mentions drawing in parts.

* fix(mcp-server): review fixes for load .drawio.svg, change summary, XML reference, draw.io embed options, truncation message

* fix(mcp-server): Codex review fixes for .drawio.svg loading, change summary, references, embed options and truncation

- Truncation check: a closing tag such as </mxCell/> that the auto-fix
  repairs is no cut, and input without any cell keeps the validator's
  message
- Guide: a call rejected as cut off drew nothing, so all of its cells are
  sent again; the topic pointer is its own paragraph
- Change summary: decoded labels (no &nbsp; or merged words from <br>),
  a fast path for equal XML, and "the order of the cells changed" as
  the fallback
- DRAWIO_UI in any case, DRAWIO_LANG=zh-Hans maps to draw.io's zh
- load_diagram and export_diagram describe which files load again
This commit is contained in:
Dayuan Jiang
2026-10-11 20:40:05 +09:00
committed by GitHub
parent cb066a4825
commit 8ab837679e
19 changed files with 949 additions and 146 deletions
+137
View File
@@ -19,6 +19,7 @@ beforeAll(() => {
import {
checkEditGate,
contentFingerprint,
describeChanges,
markPageSeen,
} from "../src/edit-gate.ts"
@@ -168,3 +169,139 @@ describe("markPageSeen", () => {
expect(markPageSeen("", live, { page_id: "A" })).toBe(live)
})
})
describe("describeChanges", () => {
const BOX2 = `<mxCell id="box2" value="World" vertex="1" parent="1"><mxGeometry x="200" y="40" width="120" height="60" as="geometry"/></mxCell>`
const XML_A_PLUS_BOX2 = XML_A.replace("</root>", `${BOX2}</root>`)
const PAGE_2 = `<diagram id="p2" name="Page-2"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram>`
const XML_A_TWO_PAGES = XML_A.replace("</mxfile>", `${PAGE_2}</mxfile>`)
it("names a moved cell with its label", () => {
const summary = describeChanges(XML_A, XML_B)
expect(summary).toBe(
'Since you last saw it, the user changed the diagram: modified box1 ("Hello").',
)
expect(summary).toContain("modified box1")
})
it("names a deleted cell", () => {
expect(describeChanges(XML_A_PLUS_BOX2, XML_A)).toContain(
'removed box2 ("World")',
)
})
it("names an added cell", () => {
expect(describeChanges(XML_A, XML_A_PLUS_BOX2)).toContain(
'added box2 ("World")',
)
})
it("is empty for draw.io's re-serialisation of the same content", () => {
expect(describeChanges(XML_A, XML_A_RESERIALIZED)).toBe("")
})
it("is empty when the model has seen nothing yet", () => {
expect(describeChanges("", XML_B)).toBe("")
})
it("is empty when one side does not parse", () => {
expect(describeChanges(XML_A, "not xml at all")).toBe("")
})
it("reports a renamed page", () => {
const renamed = XML_A.replace('name="Page-1"', 'name="Renamed"')
expect(describeChanges(XML_A, renamed)).toBe(
'Since you last saw it, the user changed the diagram. Page 1 renamed to "Renamed".',
)
})
// A bare <mxGraphModel> push carries no page name, so the invented
// "Page-1" must not read as a rename
it("never reports a rename against a bare mxGraphModel", () => {
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
expect(describeChanges(seenRenamed, XML_A_BARE)).toBe("")
const bareMoved = XML_A_BARE.replace('x="40" y="40"', 'x="300" y="200"')
const summary = describeChanges(seenRenamed, bareMoved)
expect(summary).toContain("modified box1")
expect(summary).not.toContain("renamed")
})
it("strips HTML from labels and reads UserObject labels", () => {
const html = XML_A.replace(
'value="Hello"',
'value="&lt;b&gt;Hello&lt;/b&gt;"',
)
expect(describeChanges(XML_A, html)).toContain(
'modified box1 ("Hello")',
)
const userObject = XML_A.replace(
"</root>",
`<UserObject id="u1" label="&lt;i&gt;Link&lt;/i&gt;" link="https://x.y"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell></UserObject></root>`,
)
expect(describeChanges(XML_A, userObject)).toContain(
'added u1 ("Link")',
)
})
it("keeps the words of an HTML label apart and decodes its spaces", () => {
const lines = XML_A.replace(
'value="Hello"',
'value="Line1&lt;br&gt;Line2&amp;nbsp;end &amp;amp; more"',
)
expect(describeChanges(XML_A, lines)).toContain(
'modified box1 ("Line1 Line2 end & more")',
)
})
it("cuts long labels at 30 characters", () => {
const long = "a".repeat(40)
const summary = describeChanges(
XML_A,
XML_A.replace('value="Hello"', `value="${long}"`),
)
expect(summary).toContain(`("${"a".repeat(30)}...")`)
})
it("lists at most 20 cells and counts the rest", () => {
const many = Array.from(
{ length: 25 },
(_, i) => `<mxCell id="n${i}" vertex="1" parent="1"/>`,
).join("")
const summary = describeChanges(
XML_A,
XML_A.replace("</root>", `${many}</root>`),
)
expect(summary).toContain("added n0, n1,")
expect(summary).toContain("n19 and 5 more.")
expect(summary).not.toContain("n20")
})
it("reports added and removed pages", () => {
expect(describeChanges(XML_A, XML_A_TWO_PAGES)).toBe(
"Since you last saw it, the user changed the diagram: page 2 added.",
)
expect(describeChanges(XML_A_TWO_PAGES, XML_A)).toBe(
"Since you last saw it, the user changed the diagram: page 2 removed.",
)
})
it("names the page of a cell change in a multi-page document", () => {
const movedOnPage1 = XML_A_TWO_PAGES.replace(
'x="40" y="40"',
'x="300" y="200"',
)
expect(describeChanges(XML_A_TWO_PAGES, movedOnPage1)).toContain(
'page 1 modified box1 ("Hello")',
)
})
it("falls back to a generic clause when only the cell order changed", () => {
const reordered = XML_A_PLUS_BOX2.replace(BOX2, "").replace(
'<mxCell id="box1"',
`${BOX2}<mxCell id="box1"`,
)
expect(describeChanges(XML_A_PLUS_BOX2, reordered)).toBe(
"Since you last saw it, the user changed the diagram: the order of the cells changed.",
)
})
})
@@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
import { addHistory, getHistory } from "../src/history.ts"
import {
drawioEmbedParams,
getState,
keepInHistory,
onSessionRecreate,
@@ -466,6 +467,54 @@ describe("preview page", () => {
})
})
describe("draw.io embed parameters from the host config", () => {
it("follows the system dark mode and fixes nothing else by default", () => {
expect(drawioEmbedParams({})).toBe("dark=auto")
})
it("turns dark mode on with the dark theme", () => {
// draw.io ignores ui=dark for dark mode once a dark parameter is sent
const params = drawioEmbedParams({ DRAWIO_UI: "dark" })
expect(params).toContain("dark=1")
expect(params).toContain("ui=dark")
// Unless the user asked for light mode
expect(drawioEmbedParams({ DRAWIO_UI: "dark", DRAWIO_DARK: "0" })).toBe(
"dark=0&ui=dark",
)
})
it("reads the theme in any case", () => {
expect(drawioEmbedParams({ DRAWIO_UI: "Dark" })).toBe("dark=1&ui=dark")
})
it("ignores a theme or language draw.io does not know", () => {
expect(drawioEmbedParams({ DRAWIO_UI: "neon" })).toBe("dark=auto")
expect(drawioEmbedParams({ DRAWIO_LANG: "en;drop" })).toBe("dark=auto")
expect(drawioEmbedParams({ DRAWIO_LANG: "english" })).toBe("dark=auto")
})
it("uses draw.io's name for Traditional Chinese", () => {
for (const value of ["zh-Hant", "zh-hant"]) {
expect(drawioEmbedParams({ DRAWIO_LANG: value })).toBe(
"dark=auto&lang=zh-tw",
)
}
expect(drawioEmbedParams({ DRAWIO_LANG: "ja" })).toBe(
"dark=auto&lang=ja",
)
// and "zh" for Simplified Chinese
expect(drawioEmbedParams({ DRAWIO_LANG: "zh-Hans" })).toBe(
"dark=auto&lang=zh",
)
})
it("reads dark mode in any spelling", () => {
expect(drawioEmbedParams({ DRAWIO_DARK: "TRUE" })).toBe("dark=1")
expect(drawioEmbedParams({ DRAWIO_DARK: "light" })).toBe("dark=0")
expect(drawioEmbedParams({ DRAWIO_DARK: "maybe" })).toBe("dark=auto")
})
})
describe("history restore", () => {
it("restores the entry the user picked after older entries drop", async () => {
const id = "mcp-history"
+117 -1
View File
@@ -19,6 +19,7 @@ beforeAll(() => {
import {
decompressPageContent,
extractSvgEmbeddedXml,
parseDrawioFileContent,
} from "../src/load-diagram.ts"
@@ -120,11 +121,17 @@ describe("parseDrawioFileContent", () => {
})
it("rejects non-drawio content", () => {
const r = parseDrawioFileContent("<svg><rect/></svg>")
const r = parseDrawioFileContent("<html><body/></html>")
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain("Not a draw.io file")
})
it("rejects an SVG without an embedded diagram", () => {
const r = parseDrawioFileContent("<svg><rect/></svg>")
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain("embedded diagram")
})
it("rejects a page whose content is neither XML nor compressed", () => {
const bad = `<mxfile><diagram id="a" name="Broken">!!! not a diagram !!!</diagram></mxfile>`
const r = parseDrawioFileContent(bad)
@@ -159,3 +166,112 @@ describe("hadCompressedPages", () => {
if (r.ok) expect(r.hadCompressedPages).toBe(true)
})
})
/**
* Escape text for an XML attribute the way draw.io/Chromium serialise the
* diagram into the SVG root's content attribute.
*/
function escapeAttr(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/\n/g, "&#xa;")
}
/** Build a .drawio.svg (Editable SVG) with the given mxfile embedded. */
function drawioSvg(mxfileXml: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="200px" height="100px" viewBox="-0.5 -0.5 200 100" content="${escapeAttr(mxfileXml)}"><defs/><g><rect x="40" y="40" width="120" height="60" fill="#ffffff" stroke="#000000"/></g></svg>`
}
const SVG_PROLOG = `<?xml version="1.0" encoding="UTF-8"?>
<!-- Do not edit this file with editors other than draw.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
`
describe("extractSvgEmbeddedXml", () => {
it("returns undefined for non-SVG text", () => {
expect(extractSvgEmbeddedXml(PLAIN_MXFILE)).toBeUndefined()
expect(extractSvgEmbeddedXml("hello")).toBeUndefined()
})
it("returns an empty string for an SVG without content", () => {
expect(extractSvgEmbeddedXml("<svg><rect/></svg>")).toBe("")
})
it("returns the decoded content attribute", () => {
expect(extractSvgEmbeddedXml(drawioSvg(PLAIN_MXFILE))).toBe(
PLAIN_MXFILE,
)
})
})
describe("parseDrawioFileContent with .drawio.svg", () => {
it("loads a compressed one-page mxfile embedded in an SVG", () => {
const r = parseDrawioFileContent(drawioSvg(COMPRESSED_MXFILE))
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.xml).toContain("<mxfile")
expect(r.xml).toContain("<mxGraphModel")
expect(r.xml).toContain('value="Hello"')
expect(r.xml).not.toContain("<svg")
}
})
it("accepts draw.io's XML declaration, comment and DOCTYPE prolog", () => {
const r = parseDrawioFileContent(
SVG_PROLOG + drawioSvg(COMPRESSED_MXFILE),
)
expect(r.ok).toBe(true)
if (r.ok) expect(r.xml).toContain('value="Hello"')
})
it("round-trips &quot; and &#xa; in an uncompressed embedded label", () => {
const model = MODEL_XML.replace(
'value="Hello"',
'value="say &quot;hi&quot;&#xa;next"',
)
// A pretty-printed file: the newline between tags becomes &#xa; in
// the SVG attribute, the label's entities become &amp;quot; etc.
const mxfile = `<mxfile host="app.diagrams.net">\n<diagram id="p1" name="Page-1">${model}</diagram>\n</mxfile>`
const svg = drawioSvg(mxfile)
expect(svg).toContain("&amp;quot;")
expect(svg).toContain("&amp;#xa;")
expect(svg).toContain("&gt;&#xa;&lt;diagram")
const r = parseDrawioFileContent(svg)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.xml).toContain('value="say &quot;hi&quot;&#xa;next"')
const doc = new DOMParser().parseFromString(r.xml, "text/xml")
const cell = doc.querySelector('mxCell[id="box1"]') as Element
expect(cell.getAttributeNode("value")?.value).toBe('say "hi"\nnext')
}
})
it("loads a two-page embedded mxfile as two pages", () => {
const twoPages = `<mxfile compressed="true"><diagram id="a" name="First">${drawioCompress(MODEL_XML)}</diagram><diagram id="b" name="Second">${drawioCompress(MODEL_XML.replace("Hello", "World"))}</diagram></mxfile>`
const r = parseDrawioFileContent(drawioSvg(twoPages))
expect(r.ok).toBe(true)
if (r.ok) {
const doc = new DOMParser().parseFromString(r.xml, "text/xml")
const diagrams = Array.from(
doc.querySelectorAll("diagram"),
) as Element[]
expect(diagrams).toHaveLength(2)
for (const d of diagrams) {
expect(d.querySelector("mxGraphModel")).not.toBeNull()
}
expect(r.xml).toContain('value="World"')
}
})
it("rejects an SVG whose content is not draw.io XML", () => {
const r = parseDrawioFileContent(
'<svg xmlns="http://www.w3.org/2000/svg" content="hello"><rect/></svg>',
)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain("not draw.io XML")
})
})
@@ -0,0 +1,152 @@
/**
* Tests for prepareNewDiagram's truncation check: output that stops inside an
* mxCell gets a clear error instead of one about the wrapper tags the model
* never sent.
*/
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import {
isMxCellXmlComplete,
prepareNewDiagram,
truncatedCellError,
} from "../src/new-diagram.ts"
describe("isMxCellXmlComplete", () => {
it("returns false for empty/null input", () => {
expect(isMxCellXmlComplete("")).toBe(false)
expect(isMxCellXmlComplete(null)).toBe(false)
expect(isMxCellXmlComplete(undefined)).toBe(false)
})
it("returns true for self-closing mxCell", () => {
const xml =
'<mxCell id="2" value="Hello" style="rounded=1;" vertex="1" parent="1"/>'
expect(isMxCellXmlComplete(xml)).toBe(true)
// A raw "<" in a value (escaped later by the auto-fix)
expect(
isMxCellXmlComplete(
'<mxCell id="3" value="<b>Title</b>" style="text;html=1;" vertex="1" parent="1"/>',
),
).toBe(true)
})
it("returns true for mxCell with closing tag", () => {
const xml = `<mxCell id="2" value="Hello" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
it("returns false for truncated mxCell", () => {
const xml =
'<mxCell id="2" value="Hello" style="rounded=1;" vertex="1" parent'
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false for mxCell with unclosed geometry", () => {
const xml = `<mxCell id="2" value="Hello" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120"`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false when output stops after a child of an open mxCell", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>
</mxCell>
<mxCell id="3" value="B" vertex="1" parent="1">
<mxGeometry x="100" y="0" width="80" height="40" as="geometry"/>`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false when output stops after </mxGeometry> of an open mxCell", () => {
const xml = `<mxCell id="e1" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry">
<mxPoint x="10" y="10" as="sourcePoint"/>
</mxGeometry>`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns true for a self-closing last mxCell with > in its value", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1">
<mxGeometry as="geometry"/>
</mxCell>
<mxCell id="3" value="A -> B" vertex="1" parent="1"/></root>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
it("returns true for multiple complete mxCells", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1"/>
<mxCell id="3" value="B" vertex="1" parent="1"/>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
})
describe("prepareNewDiagram with cut-off output", () => {
const A = `<mxCell id="2" value="A" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
it("rejects a cell cut off inside an attribute", () => {
const cut = `${A}<mxCell id="3" value="B" vertex="1" par`
const out = prepareNewDiagram(cut)
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.error).toContain("cut off")
// The tail is quoted so the model knows where to resume
expect(out.error).toContain('<mxCell id="3" value="B" vertex="1" par')
expect(out.error).toContain("edit_diagram add")
})
it("rejects a cell cut off after its geometry", () => {
const cut = `${A}<mxCell id="3" value="B" vertex="1" parent="1"><mxGeometry x="200" y="0" width="80" height="40" as="geometry"/>`
const out = prepareNewDiagram(cut)
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.error).toContain("cut off")
expect(out.error).not.toContain("</root>")
})
it("quotes at most the last 300 characters", () => {
const cut = `${A}<mxCell id="3" value="${"x".repeat(400)}`
const out = prepareNewDiagram(cut)
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.error).toContain(cut.slice(-300))
expect(out.error).not.toContain(cut.slice(-301))
})
it("does not report complete cells followed by an XML comment as cut", () => {
const cells = `${A}<!-- end of the diagram -->`
expect(truncatedCellError(cells)).toBeNull()
expect(prepareNewDiagram(cells).ok).toBe(true)
})
it("leaves a closing tag the auto-fix repairs to the auto-fix", () => {
// </mxCell/> is a typo, not a cut
const typo = A.replace("</mxCell>", "</mxCell/>")
expect(truncatedCellError(typo)).toBeNull()
expect(prepareNewDiagram(typo).ok).toBe(true)
})
it("leaves input without any cell to the validator", () => {
for (const input of ["", "Here is the diagram"]) {
expect(truncatedCellError(input)).toBeNull()
const out = prepareNewDiagram(input)
expect(out.ok).toBe(false)
if (!out.ok) expect(out.error).not.toContain("cut off")
}
})
it("never checks a full <mxfile>", () => {
const cut = `<mxfile><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="A" vertex="1" par`
expect(truncatedCellError(cut)).toBeNull()
const out = prepareNewDiagram(cut)
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.error).not.toContain("cut off")
})
})
@@ -257,6 +257,25 @@ describe("MCP server wiring", () => {
text.endsWith(`\n\n## Custom Instructions\n${CUSTOM_RULE}`),
).toBe(true)
})
it("advertises topic on get_drawing_guide", async () => {
const resp = await send("tools/list", {})
const guide = resp.result.tools.find(
(t: { name: string }) => t.name === "get_drawing_guide",
)
expect(guide?.inputSchema?.properties?.topic).toBeTruthy()
})
it("serves the table XML reference with topic=tables", async () => {
const resp = await send("tools/call", {
name: "get_drawing_guide",
arguments: { topic: "tables" },
})
expect(resp.result.isError).toBeFalsy()
const text: string = resp.result.content[0].text
expect(text).toContain("shape=table")
expect(text).not.toContain("Edge routing rules")
})
})
describe("load_diagram dual-source arguments", () => {
@@ -0,0 +1,58 @@
/**
* The XML references returned by get_drawing_guide with a topic. Each
* snippet must be accepted by create_new_diagram as bare mxCell elements;
* whether draw.io renders it as intended is checked by hand.
*/
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import { prepareNewDiagram } from "../src/new-diagram.ts"
import { hasCells } from "../src/pages.ts"
import { XML_REFERENCE } from "../src/xml-reference.ts"
/** The code block of a reference: the lines indented by four spaces. */
function snippet(topic: keyof typeof XML_REFERENCE): string {
return XML_REFERENCE[topic]
.split("\n")
.filter((line) => line.startsWith(" "))
.map((line) => line.slice(4))
.join("\n")
}
describe("XML_REFERENCE", () => {
it.each(["tables", "layers", "groups"] as const)(
"%s snippet passes prepareNewDiagram",
(topic) => {
const xml = snippet(topic)
expect(xml).toContain("<mxCell")
const result = prepareNewDiagram(xml)
expect(result.ok, result.ok ? "" : result.error).toBe(true)
if (result.ok) expect(result.fixes).toEqual([])
},
)
it("tables use draw.io's table shapes", () => {
const xml = snippet("tables")
expect(xml).toContain("shape=table;")
expect(xml).toContain("shape=tableRow;")
expect(xml).toContain("shape=partialRectangle;")
})
it('layers keep the layer cell with parent="0"', () => {
const result = prepareNewDiagram(snippet("layers"))
expect(result.ok).toBe(true)
if (!result.ok) return
expect(hasCells(result.xml)).toBe(true)
expect(result.xml).toContain('id="L2"')
expect(result.xml).toContain('parent="L2"')
})
it("groups use a group container", () => {
expect(snippet("groups")).toContain('style="group"')
})
})