diff --git a/lib/utils.ts b/lib/utils.ts index 20ecd5df..178404db 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -2,8 +2,6 @@ import { type ClassValue, clsx } from "clsx" import * as pako from "pako" import { twMerge } from "tailwind-merge" import { hasCells } from "@/packages/mcp-server/src/pages.ts" -import { readStyleClasses } from "@/packages/mcp-server/src/style-classes.ts" -import { repairQuoteBeforeSlash } from "@/packages/mcp-server/src/xml-validation.ts" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -34,55 +32,7 @@ export function isRealDiagram(xml: string | undefined | null): boolean { // mxCell XML Helpers // ============================================================================ -/** - * Check if mxCell XML output is complete (not truncated). - * Complete XML ends with a self-closing tag (/>) or closing mxCell tag. - * Uses a robust approach that handles any LLM provider's wrapper tags - * by finding the last valid mxCell ending and checking if suffix is just closing tags. - * @param xml - The XML string to check (can be undefined/null) - * @returns true if XML appears complete, false if truncated or empty - */ -export function isMxCellXmlComplete(xml: string | undefined | null): boolean { - // Named style definitions before the cells are not cells: output cut off - // right after them is incomplete. A compact cell whose last quote is - // missing is complete; prepareNewDiagram repairs it. - const trimmed = repairQuoteBeforeSlash( - readStyleClasses(xml || "").xml, - ).trim() - if (!trimmed) return false - - // Find position of last complete mxCell ending (either /> or ) - const lastSelfClose = trimmed.lastIndexOf("/>") - const lastMxCellClose = trimmed.lastIndexOf("") - - const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose) - - // No valid ending found at all - if (lastValidEnd === -1) return false - - // If the last mxCell has no after it, it must be self-closing. - // Otherwise the trailing "/>" belongs to a child such as - // and the output was cut off before the cell was closed. - const lastCellStart = trimmed.lastIndexOf(" lastMxCellClose && - // (quoted values may hold a raw "<", which the auto-fix escapes) - !/^"']|"[^"]*"|'[^']*')*\/>/.test( - trimmed.slice(lastCellStart), - ) - ) { - return false - } - - // Check what comes after the last valid ending - // For />: add 2 chars, for : add 9 chars - const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2 - const suffix = trimmed.slice(lastValidEnd + endOffset) - - // If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete - // This regex matches any sequence of closing XML tags like , , - return /^(\s*<\/[^>]+>)*\s*$/.test(suffix) -} +export { isMxCellXmlComplete } from "@/packages/mcp-server/src/new-diagram.ts" /** * Extract only complete mxCell elements from partial/streaming XML. diff --git a/packages/claude-plugin/README.md b/packages/claude-plugin/README.md index af9e06a6..0f41d5b1 100644 --- a/packages/claude-plugin/README.md +++ b/packages/claude-plugin/README.md @@ -94,7 +94,7 @@ Recreate the whiteboard photo at ~/Desktop/sketch.jpg as a clean draw.io diagram | `get_drawing_guide` | Return the drawing rules again | | `get_shape_library` | Return the shapes and icon styles of a library such as `aws4` | | `create_new_diagram` | Create a new diagram from XML | -| `load_diagram` | Load a `.drawio` file from disk | +| `load_diagram` | Load a `.drawio` or `.drawio.svg` file from disk | | `edit_diagram` | Edit diagram by ID-based operations; all or nothing | | `get_diagram` | Get the current diagram XML | | `screenshot_diagram` | Return a PNG of a page so Claude can check the result | @@ -120,6 +120,9 @@ Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io) | `PORT` | `6002` | Port for the embedded HTTP server | | `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) | | `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for auto-saved diagrams and your `instructions.md`; `off` turns auto-save off (`instructions.md` is then read from the default folder) | +| `DRAWIO_LANG` | unset | Language of the draw.io editor, such as `en`, `zh`, `zh-tw`, `ja` or `de`. Unset, draw.io chooses (the browser language on `embed.diagrams.net`, English on a self-hosted draw.io) and the user can change it under **Extras > Language** | +| `DRAWIO_UI` | unset | draw.io theme: `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple`. Unset, the user picks one under **Extras > Theme** | +| `DRAWIO_DARK` | `auto` | Dark mode of the draw.io editor: `auto` follows the system, `1` forces dark, `0` forces light | | `DEBUG` | unset | Set to `true` to log debug messages | ## Links diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index ea8f0b6b..fc97d7a2 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -112,11 +112,11 @@ Use the standard MCP configuration with: - **Edit Support**: Modify existing diagrams with natural language instructions. If any change in an edit fails, nothing is written and the AI gets the reason and the current page XML - **Your Edits Are Kept**: Changes you make in the browser are read before the AI edits again. If the AI overwrites a change you were still making, your version is saved in History - **Version History**: Click **History** at the top right of the preview page to restore one of the last 20 versions, shown as thumbnails, or ask the AI to undo (`restore_version`) -- **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io can open and edit again), from the **Download** button or through `export_diagram` +- **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io and `load_diagram` can open again), from the **Download** button or through `export_diagram` - **Multi-page**: List, add, rename, and delete pages, and edit any page - **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/.drawio`, and its last 20 versions to `.history.json`, so the diagram, History and undo survive a restart of the MCP client - **Custom Instructions**: keep your own drawing rules in `~/.next-ai-drawio/instructions.md` (for example "Always draw in minimal style"); they are appended to the drawing guide on every call -- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode +- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode, or fix them with `DRAWIO_UI`, `DRAWIO_DARK` and `DRAWIO_LANG` - **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`) ## Available Tools @@ -125,10 +125,10 @@ Use the standard MCP configuration with: |------|-------------| | `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules. Pass `session_id` to continue a saved diagram | | `list_saved_diagrams` | List the auto-saved diagrams of earlier sessions, newest first, with their pages | -| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted | +| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted, or, with `topic`, a short XML reference for tables, layers or groups | | `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 into the session, from a `path` on disk or from its `xml` content (handles compressed files) | +| `load_diagram` | Load a `.drawio` or `.drawio.svg` 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 | @@ -185,6 +185,9 @@ To give the AI your own drawing rules, write them in `~/.next-ai-drawio/instruct | `PORT` | `6002` | Port for the embedded HTTP server | | `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. | | `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for the auto-saved `.drawio` files and your `instructions.md`. Set to `off` to turn auto-save off (`instructions.md` is then read from the default folder). | +| `DRAWIO_LANG` | unset | Language of the draw.io editor. Unset, draw.io chooses: the browser language on `embed.diagrams.net`, English on a self-hosted draw.io until the user picks one under **Extras > Language**. A code such as `en`, `zh`, `zh-tw`, `ja` or `de` fixes it and hides that submenu. | +| `DRAWIO_UI` | unset | draw.io theme. Unset, the user picks one under **Extras > Theme** and draw.io remembers it. `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple` fixes the theme and hides that menu. | +| `DRAWIO_DARK` | `auto` | Dark mode of the draw.io editor: `auto` follows the system, `1` forces dark, `0` forces light. The page header keeps following the system. | | `DEBUG` | unset | Set to `true` to log debug messages to stderr. | ### Private Deployment (Self-hosted draw.io) diff --git a/packages/mcp-server/src/drawing-guide.ts b/packages/mcp-server/src/drawing-guide.ts index 1c171eba..e8c6cdef 100644 --- a/packages/mcp-server/src/drawing-guide.ts +++ b/packages/mcp-server/src/drawing-guide.ts @@ -52,6 +52,8 @@ Containers and swimlanes: children use the container id as parent and coordinate ${indent(SWIMLANE_EXAMPLE)} +Large diagrams: if the diagram will not fit one call, send the first cells with create_new_diagram and add the rest with edit_diagram add operations (one per cell, same id rules). A call rejected because the XML ends inside an unfinished cell drew nothing: send all of its cells again, in smaller parts if needed. + ## Layout - Keep every element of a page within x 0 to 800 and y 0 to 600, so the whole diagram fits one view without a page break. - Containers (for example AWS cloud boxes) are at most 700 pixels wide and 550 pixels tall. @@ -92,6 +94,9 @@ ${indent(STYLE_CLASS_EXAMPLE)} - Edges: endArrow=block, open or none; startArrow=classic; curved=1; dashed=1; edgeStyle=orthogonalEdgeStyle - Text: fontSize=14, fontStyle=1 (bold), align=center or right - Animated connectors: add flowAnimation=1 to the edge style. +- Edge options: dashed=1; strokeWidth=2; edgeStyle=elbowEdgeStyle;elbow=vertical (or horizontal); jumpStyle=arc;jumpSize=10 where edges cross. + +For tables, layers or groups, call get_drawing_guide with topic=tables, layers or groups. ## Minimal style When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above: diff --git a/packages/mcp-server/src/drawio-themes.ts b/packages/mcp-server/src/drawio-themes.ts new file mode 100644 index 00000000..a43b0615 --- /dev/null +++ b/packages/mcp-server/src/drawio-themes.ts @@ -0,0 +1,24 @@ +export const DRAWIO_THEMES = [ + "kennedy", + "atlas", + "dark", + "min", + "sketch", + "simple", +] as const + +export type DrawioTheme = (typeof DRAWIO_THEMES)[number] + +export function isDrawioTheme(value: unknown): value is DrawioTheme { + return ( + typeof value === "string" && + (DRAWIO_THEMES as readonly string[]).includes(value) + ) +} + +/** draw.io's name for a locale: lowercase, "zh-tw" for Traditional and "zh" for Simplified Chinese */ +export function toDrawioLang(locale: string): string { + const lang = locale.toLowerCase() + if (lang === "zh-hant") return "zh-tw" + return lang === "zh-hans" ? "zh" : lang +} diff --git a/packages/mcp-server/src/edit-gate.ts b/packages/mcp-server/src/edit-gate.ts index f80240b2..2ce81a2c 100644 --- a/packages/mcp-server/src/edit-gate.ts +++ b/packages/mcp-server/src/edit-gate.ts @@ -142,3 +142,116 @@ export function markPageSeen( ? liveXml : lastSeenXml } + +const CELL_TAGS = new Set(["mxCell", "UserObject", "object"]) + +/** The cells of a page by id: the direct children of its . */ +function pageCells(page: Element): Map { + const cells = new Map() + for (const el of Array.from(page.querySelector("root")?.children ?? [])) { + const id = el.getAttribute("id") + if (id && CELL_TAGS.has(el.tagName)) cells.set(id, el) + } + return cells +} + +/** `id ("label")`: the cell's value without HTML, cut at 30 characters. */ +function describeCell(id: string, cell: Element): string { + // getAttributeNode gives the decoded value (linkedom's getAttribute + // escapes it again); an HTML label then still holds tags and entities + const label = ( + (cell.getAttributeNode("value") ?? cell.getAttributeNode("label")) + ?.value ?? "" + ) + .replace(/<[^>]*>/g, " ") + .replace(/ /g, " ") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/&/g, "&") + .replace(/\s+/g, " ") + .trim() + if (!label) return id + return `${id} ("${label.length > 30 ? `${label.slice(0, 30)}...` : label}")` +} + +/** At most 20 entries, then "and N more". */ +function listCells(cells: string[]): string { + const shown = cells.slice(0, 20).join(", ") + return cells.length > 20 ? `${shown} and ${cells.length - 20} more` : shown +} + +/** + * One paragraph naming what the user changed between the XML the model last + * saw and the live store, for stale rejections and get_diagram. Pages are + * paired by index (draw.io regenerates diagram ids on re-serialisation) and + * cells by id. Returns "" when there is nothing to compare or the two sides + * have the same content. + */ +export function describeChanges(lastSeenXml: string, liveXml: string): string { + if (!lastSeenXml || lastSeenXml === liveXml) return "" + const parse = (xml: string) => { + const normalized = normalizeToMxfile(xml) + return normalized ? parseMxfile(normalized) : null + } + const before = parse(lastSeenXml) + const live = parse(liveXml) + if (!before || !live) return "" + // Same rule as checkEditGate: a bare carries no page name + const includeNames = + !isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml) + if ( + contentFingerprint(lastSeenXml, includeNames) === + contentFingerprint(liveXml, includeNames) + ) + return "" + + const pagesBefore = Array.from(before.querySelectorAll("diagram")) + const pagesLive = Array.from(live.querySelectorAll("diagram")) + const pageCount = Math.max(pagesBefore.length, pagesLive.length) + const clauses: string[] = [] + const renames: string[] = [] + for (let i = 0; i < pageCount; i++) { + const pageBefore = pagesBefore[i] + const pageLive = pagesLive[i] + if (!pageBefore || !pageLive) { + clauses.push(`page ${i + 1} ${pageLive ? "added" : "removed"}`) + continue + } + const cellsBefore = pageCells(pageBefore) + const cellsLive = pageCells(pageLive) + const modified: string[] = [] + const added: string[] = [] + const removed: string[] = [] + for (const [id, cell] of cellsLive) { + const old = cellsBefore.get(id) + if (!old) added.push(describeCell(id, cell)) + else if (canonicalizeElement(old) !== canonicalizeElement(cell)) + modified.push(describeCell(id, cell)) + } + for (const [id, cell] of cellsBefore) { + if (!cellsLive.has(id)) removed.push(describeCell(id, cell)) + } + // Name the page only when the document has several + const where = pageCount > 1 ? `page ${i + 1} ` : "" + if (modified.length) + clauses.push(`${where}modified ${listCells(modified)}`) + if (added.length) clauses.push(`${where}added ${listCells(added)}`) + if (removed.length) + clauses.push(`${where}removed ${listCells(removed)}`) + if (includeNames) { + const name = pageLive.getAttribute("name") ?? "" + if (name !== (pageBefore.getAttribute("name") ?? "")) + renames.push(`Page ${i + 1} renamed to "${name}".`) + } + } + // The fingerprint also covers the cell order (z-order) and anything in + // that is not a cell + if (!clauses.length && !renames.length) + clauses.push("the order of the cells changed") + const intro = "Since you last saw it, the user changed the diagram" + const body = clauses.length + ? `${intro}: ${clauses.join("; ")}.` + : `${intro}.` + return [body, ...renames].join(" ") +} diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index 4b67e87a..ced602ba 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -44,6 +44,7 @@ function readBody( }) } +import { isDrawioTheme, toDrawioLang } from "./drawio-themes.ts" import { contentFingerprint } from "./edit-gate.ts" import { addHistory, @@ -813,6 +814,33 @@ function loadPreviewTemplate(): string { const scriptJson = (value: string) => JSON.stringify(value).replace(/ Language / Theme submenu once they are given. + */ +export function drawioEmbedParams( + env: NodeJS.ProcessEnv = process.env, +): string { + const params = new URLSearchParams() + const ui = (env.DRAWIO_UI ?? "").toLowerCase() + const dark = (env.DRAWIO_DARK ?? "").toLowerCase() + const lang = toDrawioLang(env.DRAWIO_LANG ?? "") + if (["1", "true", "dark"].includes(dark)) { + params.set("dark", "1") + } else if (["0", "false", "light"].includes(dark)) { + params.set("dark", "0") + } else { + // draw.io takes ui=dark as dark mode only when no dark parameter is + // present, and this page always sends one + params.set("dark", !dark && ui === "dark" ? "1" : "auto") + } + if (/^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang)) params.set("lang", lang) + if (isDrawioTheme(ui)) params.set("ui", ui) + return params.toString() +} + function getHtmlPage(sessionId: string): string { return loadPreviewTemplate() .replace("{{SESSION_BADGE}}", () => @@ -822,6 +850,7 @@ function getHtmlPage(sessionId: string): string { ) .replaceAll("{{DISABLED}}", sessionId ? "" : "disabled") .replace("{{DRAWIO_URL}}", () => normalizeUrl(DRAWIO_BASE_URL)) + .replace("{{DRAWIO_PARAMS}}", () => drawioEmbedParams()) .replace("{{SESSION_JSON}}", () => scriptJson(sessionId)) .replace("{{ORIGIN_JSON}}", () => scriptJson(DRAWIO_ORIGIN)) } diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 0a0c3f1a..f02ae8a7 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -32,7 +32,12 @@ import type { DiagramOperation } from "./diagram-operations.ts" import { installDomPolyfill } from "./dom.ts" import { DRAWING_GUIDE } from "./drawing-guide.ts" import { editDiagram, targetPageXml } from "./edit-diagram.ts" -import { checkEditGate, contentFingerprint, markPageSeen } from "./edit-gate.ts" +import { + checkEditGate, + contentFingerprint, + describeChanges, + markPageSeen, +} from "./edit-gate.ts" import { createExclusive } from "./exclusive.ts" import { addHistory, @@ -64,6 +69,7 @@ import { prepareNewDiagram, reservedIdError, takeStyleDefinitions, + truncatedCellError, } from "./new-diagram.ts" import { addPageToDoc, @@ -84,6 +90,7 @@ import { import { Autosaver, defaultDataDir, expandHome } from "./persistence.ts" import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts" import { addDefaultStyles, applyStyleClasses } from "./style-classes.ts" +import { XML_REFERENCE } from "./xml-reference.ts" import { validateAndFixXml } from "./xml-validation.ts" // DOMParser/XMLSerializer globals for the XML helpers (Node has neither) @@ -163,10 +170,10 @@ After drawing a complex diagram, call screenshot_diagram once to see it, and fix Tools: - create_new_diagram: draw a new diagram, replacing the whole document. Send only the mxCell elements of one page (the server adds the wrapper and root cells), or a full for several pages. -- edit_diagram: add, update or delete cells of an existing page by id. All-or-nothing; a rejected call includes the current XML so you can retry. +- edit_diagram: add, update or delete cells of an existing page by id. All-or-nothing; a rejected call includes the current XML so you can retry; also used to draw a large diagram in parts. - get_diagram: read the current XML, including the user's manual edits. - screenshot_diagram: see the rendered diagram as an image. -- load_diagram, export_diagram: open or save .drawio files and export .png or .svg. Use absolute paths. +- load_diagram, export_diagram: open or save .drawio or .drawio.svg files and export .png or .svg. Use absolute paths. - list_saved_diagrams: diagrams saved by earlier sessions; continue one with start_session session_id=. - list_pages, add_page, rename_page, delete_page: manage pages (tabs). - restore_version: undo; go back to an earlier version of the diagram.` @@ -272,11 +279,26 @@ server.registerTool( title: "Get drawing guide", description: "Return the drawing guide: XML format, layout, edge routing, style and editing rules. " + - "start_session already returns it; call this only if the guide is no longer in your context.", - inputSchema: {}, + "start_session already returns it; call this only if the guide is no longer in your context. " + + "With topic, returns a short XML reference for tables, layers or groups.", + inputSchema: { + topic: z + .enum(["tables", "layers", "groups"]) + .optional() + .describe( + "Return only the XML reference for this topic instead of the whole guide.", + ), + }, annotations: { readOnlyHint: true, openWorldHint: false }, }, - async () => ({ content: [{ type: "text", text: guideText() }] }), + async ({ topic }) => ({ + content: [ + { + type: "text", + text: topic ? XML_REFERENCE[topic] : guideText(), + }, + ], + }), ) // Tool: get_shape_library @@ -610,12 +632,13 @@ To clear the canvas to one blank page, send only the two root cells (all pages).\n" + @@ -1821,6 +1857,13 @@ registerWriteTool( isError: true, } } + const truncated = startXml && truncatedCellError(startXml) + if (truncated) { + return { + content: [{ type: "text", text: `Error: ${truncated}` }], + isError: true, + } + } let cleanXml: string | undefined = startXml && wrapCellsInModel(startXml) if (cleanXml) { diff --git a/packages/mcp-server/src/load-diagram.ts b/packages/mcp-server/src/load-diagram.ts index 9dbcb5f9..ab395762 100644 --- a/packages/mcp-server/src/load-diagram.ts +++ b/packages/mcp-server/src/load-diagram.ts @@ -43,11 +43,27 @@ export function decompressPageContent(compressed: string): string | null { } } +/** + * Read the diagram XML a .drawio.svg (Editable SVG) carries in its root + * element's content attribute. Returns undefined when the text is not an + * SVG, and "" when the SVG has no embedded diagram. + * + * Uses getAttributeNode().value rather than getAttribute(): linkedom's + * getAttribute re-escapes <, > and & in XML documents, which would turn + * the embedded " whose every page holds plain XML. Accepts a - * bare (wrapped into a one-page mxfile) and decompresses - * any compressed pages. + * bare (wrapped into a one-page mxfile), a .drawio.svg with + * the diagram embedded, and decompresses any compressed pages. */ export function parseDrawioFileContent(content: string): LoadResult { let trimmed = content.trim() @@ -62,9 +78,25 @@ export function parseDrawioFileContent(content: string): LoadResult { trimmed = normalized } if (!isMxFile(trimmed)) { + const embedded = extractSvgEmbeddedXml(trimmed) + if (embedded === undefined) { + return { + ok: false, + error: "Not a draw.io file: expected an or root element, or a .drawio.svg with the diagram embedded.", + } + } + if (!embedded) { + return { + ok: false, + error: "This SVG has no embedded diagram. Only .drawio.svg (Editable SVG) files can be loaded; ask for the .drawio file or redraw from the image.", + } + } + if (isMxFile(embedded) || isMxGraphModel(embedded)) { + return parseDrawioFileContent(embedded) + } return { ok: false, - error: "Not a draw.io file: expected an or root element.", + error: "The SVG's embedded content is not draw.io XML.", } } const doc = parseMxfile(trimmed) diff --git a/packages/mcp-server/src/new-diagram.ts b/packages/mcp-server/src/new-diagram.ts index cc9669f7..68617ed7 100644 --- a/packages/mcp-server/src/new-diagram.ts +++ b/packages/mcp-server/src/new-diagram.ts @@ -12,7 +12,7 @@ import { } from "./style-classes.ts" import { readAttributes } from "./xml-attributes.ts" import { getXmlSyntaxError } from "./xml-syntax.ts" -import { validateAndFixXml } from "./xml-validation.ts" +import { repairQuoteBeforeSlash, validateAndFixXml } from "./xml-validation.ts" export type NewDiagram = | { ok: true; xml: string; fixes: string[] } @@ -53,6 +53,74 @@ export function reservedIdError(input: string): string | null { return null } +/** + * Check if mxCell XML output is complete (not truncated). + * Complete XML ends with a self-closing tag (/>) or closing mxCell tag. + * Uses a robust approach that handles any LLM provider's wrapper tags + * by finding the last valid mxCell ending and checking if suffix is just closing tags. + * @param xml - The XML string to check (can be undefined/null) + * @returns true if XML appears complete, false if truncated or empty + */ +export function isMxCellXmlComplete(xml: string | undefined | null): boolean { + // Named style definitions before the cells are not cells: output cut off + // right after them is incomplete. A compact cell whose last quote is + // missing is complete; prepareNewDiagram repairs it. + const trimmed = repairQuoteBeforeSlash( + readStyleClasses(xml || "").xml, + ).trim() + if (!trimmed) return false + + // Find position of last complete mxCell ending (either /> or ) + const lastSelfClose = trimmed.lastIndexOf("/>") + const lastMxCellClose = trimmed.lastIndexOf("") + + const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose) + + // No valid ending found at all + if (lastValidEnd === -1) return false + + // If the last mxCell has no after it, it must be self-closing. + // Otherwise the trailing "/>" belongs to a child such as + // and the output was cut off before the cell was closed. + const lastCellStart = trimmed.lastIndexOf(" lastMxCellClose && + // (quoted values may hold a raw "<", which the auto-fix escapes) + !/^"']|"[^"]*"|'[^']*')*\/>/.test( + trimmed.slice(lastCellStart), + ) + ) { + return false + } + + // Check what comes after the last valid ending + // For />: add 2 chars, for : add 9 chars + const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2 + const suffix = trimmed.slice(lastValidEnd + endOffset) + + // If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete + // This regex matches any sequence of closing XML tags like , , + return /^(\s*<\/[^>]+>)*\s*$/.test(suffix) +} + +/** + * Output that stops inside an mxCell would be wrapped and rejected with an + * error about wrapper tags the model never sent. Bare cells only. Returns the + * error for the model, or null. + */ +export function truncatedCellError(input: string): string | null { + if (/<(mxGraphModel|mxfile)\b/.test(input)) return null + // Comments, and closing tags such as that the auto-fix + // repairs, are no cut + const stripped = input + .replace(//g, "") + .replace(/<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g, "") + // No cell at all: the validator's message fits better + if (!/ - +
diff --git a/packages/mcp-server/src/xml-reference.ts b/packages/mcp-server/src/xml-reference.ts new file mode 100644 index 00000000..5c8b353f --- /dev/null +++ b/packages/mcp-server/src/xml-reference.ts @@ -0,0 +1,71 @@ +/** + * Short XML references returned by get_drawing_guide with a topic. Each + * snippet is the bare mxCell elements of one page, as create_new_diagram + * accepts them, and is checked by tests/xml-reference.test.ts. + */ + +import { indent } from "./xml-examples.ts" + +const TABLE_ROW_STYLE = + "shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;" + +const TABLE_CELL_STYLE = + "shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;" + +const TABLES_EXAMPLE = ` + + + + + + + + + + + + + + + + + + + +` + +const LAYERS_EXAMPLE = ` + + + + + +` + +const GROUPS_EXAMPLE = ` + + + + + + + +` + +export const XML_REFERENCE: Record<"tables" | "layers" | "groups", string> = { + tables: `## Tables +A table is three levels of cells, as draw.io's sidebar inserts them: the table container (style "shape=table;startSize=0;container=1;collapsible=0;childLayout=tableLayout;", parent="1", geometry wide enough for all columns), one row per table row (style as below, parent = table id, width = table width, height = row height, y = row offset), and one cell per column inside each row (style as below, parent = row id, x = column offset, y = 0, width = column width, height = row height; the cell text is the value). A header row can use fontStyle=1 on its cells. Example with 2 rows and 2 columns: + +${indent(TABLES_EXAMPLE)} +`, + layers: `## Layers +A layer is the one case of a cell with parent="0" and no vertex or edge attribute; id="1" is the default layer. Add a layer cell and give shapes parent="" to put them on it: + +${indent(LAYERS_EXAMPLE)} +`, + groups: `## Groups +A group is a container with style="group" (parent="1", geometry enclosing its children). Children use parent="" and coordinates relative to the group; moving the group moves them: + +${indent(GROUPS_EXAMPLE)} +`, +} diff --git a/packages/mcp-server/tests/edit-gate.test.ts b/packages/mcp-server/tests/edit-gate.test.ts index 66df1fce..e5cf6a78 100644 --- a/packages/mcp-server/tests/edit-gate.test.ts +++ b/packages/mcp-server/tests/edit-gate.test.ts @@ -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 = `` + const XML_A_PLUS_BOX2 = XML_A.replace("", `${BOX2}`) + const PAGE_2 = `` + const XML_A_TWO_PAGES = XML_A.replace("", `${PAGE_2}`) + + 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 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="<b>Hello</b>"', + ) + expect(describeChanges(XML_A, html)).toContain( + 'modified box1 ("Hello")', + ) + const userObject = XML_A.replace( + "", + ``, + ) + 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<br>Line2&nbsp;end &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) => ``, + ).join("") + const summary = describeChanges( + XML_A, + XML_A.replace("", `${many}`), + ) + 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( + ' { }) }) +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" diff --git a/packages/mcp-server/tests/load-diagram.test.ts b/packages/mcp-server/tests/load-diagram.test.ts index afce3463..1dc12a2e 100644 --- a/packages/mcp-server/tests/load-diagram.test.ts +++ b/packages/mcp-server/tests/load-diagram.test.ts @@ -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("") + const r = parseDrawioFileContent("") 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("") + 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 = `!!! not a diagram !!!` 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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/\n/g, " ") +} + +/** Build a .drawio.svg (Editable SVG) with the given mxfile embedded. */ +function drawioSvg(mxfileXml: string): string { + return `` +} + +const SVG_PROLOG = ` + + +` + +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("")).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(" { + 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 " and in an uncompressed embedded label", () => { + const model = MODEL_XML.replace( + 'value="Hello"', + 'value="say "hi" next"', + ) + // A pretty-printed file: the newline between tags becomes in + // the SVG attribute, the label's entities become &quot; etc. + const mxfile = `\n${model}\n` + const svg = drawioSvg(mxfile) + expect(svg).toContain("&quot;") + expect(svg).toContain("&#xa;") + expect(svg).toContain("> <diagram") + + const r = parseDrawioFileContent(svg) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.xml).toContain('value="say "hi" 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 = `${drawioCompress(MODEL_XML)}${drawioCompress(MODEL_XML.replace("Hello", "World"))}` + 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( + '', + ) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toContain("not draw.io XML") + }) +}) diff --git a/packages/mcp-server/tests/new-diagram.test.ts b/packages/mcp-server/tests/new-diagram.test.ts new file mode 100644 index 00000000..b935ba4f --- /dev/null +++ b/packages/mcp-server/tests/new-diagram.test.ts @@ -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 = + '' + expect(isMxCellXmlComplete(xml)).toBe(true) + // A raw "<" in a value (escaped later by the auto-fix) + expect( + isMxCellXmlComplete( + '', + ), + ).toBe(true) + }) + + it("returns true for mxCell with closing tag", () => { + const xml = ` + + ` + expect(isMxCellXmlComplete(xml)).toBe(true) + }) + + it("returns false for truncated mxCell", () => { + const xml = + ' { + const xml = ` + { + const xml = ` + + + + ` + expect(isMxCellXmlComplete(xml)).toBe(false) + }) + + it("returns false when output stops after of an open mxCell", () => { + const xml = ` + + + ` + expect(isMxCellXmlComplete(xml)).toBe(false) + }) + + it("returns true for a self-closing last mxCell with > in its value", () => { + const xml = ` + + + ` + expect(isMxCellXmlComplete(xml)).toBe(true) + }) + + it("returns true for multiple complete mxCells", () => { + const xml = ` + ` + expect(isMxCellXmlComplete(xml)).toBe(true) + }) +}) + +describe("prepareNewDiagram with cut-off output", () => { + const A = `` + + it("rejects a cell cut off inside an attribute", () => { + const cut = `${A} { + const cut = `${A}` + const out = prepareNewDiagram(cut) + expect(out.ok).toBe(false) + if (out.ok) return + expect(out.error).toContain("cut off") + expect(out.error).not.toContain("") + }) + + it("quotes at most the last 300 characters", () => { + const cut = `${A} { + const cells = `${A}` + expect(truncatedCellError(cells)).toBeNull() + expect(prepareNewDiagram(cells).ok).toBe(true) + }) + + it("leaves a closing tag the auto-fix repairs to the auto-fix", () => { + // is a typo, not a cut + const typo = A.replace("", "") + 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 ", () => { + const cut = ` { 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", () => { diff --git a/packages/mcp-server/tests/xml-reference.test.ts b/packages/mcp-server/tests/xml-reference.test.ts new file mode 100644 index 00000000..6d867d79 --- /dev/null +++ b/packages/mcp-server/tests/xml-reference.test.ts @@ -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(" { + 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"') + }) +}) diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index 8bb3087a..e0e89fdc 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -4,7 +4,6 @@ import { cn, extractCompleteMxCells, extractDiagramXML, - isMxCellXmlComplete, isRealDiagram, replaceNodes, } from "@/lib/utils" @@ -36,76 +35,6 @@ describe("isRealDiagram", () => { }) }) -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 = - '' - expect(isMxCellXmlComplete(xml)).toBe(true) - // A raw "<" in a value (escaped later by the auto-fix) - expect( - isMxCellXmlComplete( - '', - ), - ).toBe(true) - }) - - it("returns true for mxCell with closing tag", () => { - const xml = ` - - ` - expect(isMxCellXmlComplete(xml)).toBe(true) - }) - - it("returns false for truncated mxCell", () => { - const xml = - ' { - const xml = ` - { - const xml = ` - - - - ` - expect(isMxCellXmlComplete(xml)).toBe(false) - }) - - it("returns false when output stops after of an open mxCell", () => { - const xml = ` - - - ` - expect(isMxCellXmlComplete(xml)).toBe(false) - }) - - it("returns true for a self-closing last mxCell with > in its value", () => { - const xml = ` - - - ` - expect(isMxCellXmlComplete(xml)).toBe(true) - }) - - it("returns true for multiple complete mxCells", () => { - const xml = ` - ` - expect(isMxCellXmlComplete(xml)).toBe(true) - }) -}) - describe("cn (class name utility)", () => { it("merges class names", () => { expect(cn("foo", "bar")).toBe("foo bar")