mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
refactor(mcp-server): remove dead code and fold repeated tool plumbing
- index.ts: registerTool() does the session check, try/catch and the
write queue for all 15 tools; fail()/ok(), syncFromBrowser(),
commitDocument() and describePages() replace the copied blocks; drop
pickPageSelector, currentSession.version, the input ?? {} defaults;
add_page reuses prepareNewModel() split out of prepareNewDiagram
- xml-validation.ts: drop the hand-written syntax checks that saxes
already reports, MAX_XML_SIZE and the stale header
- http-server.ts: one pendingExport object, no unread response fields,
no CORS/OPTIONS branch for same-origin requests, fewer exports
- diagram-operations parseNewCell(), addHistory returns void,
normalizeToMxfile without host, foldCells without layer, expandStyles
removed, unused preview.css rule removed, no .d.ts output
- xml-examples.ts styleRules() shares the Styles paragraphs with the
web prompt
- tests: setupFiles installs the DOM polyfill once, server-wiring uses
the SDK client, mcp-core.test.ts drops the cases MCP tests already
cover
This commit is contained in:
@@ -158,7 +158,7 @@ function plainGeometry(body: string): Map<string, string> | null {
|
|||||||
* missing one. Everything else, including wrapped cells' extra data and
|
* missing one. Everything else, including wrapped cells' extra data and
|
||||||
* edge labels, is left as written.
|
* edge labels, is left as written.
|
||||||
*/
|
*/
|
||||||
export function foldCells(xml: string, layer = "1"): string {
|
export function foldCells(xml: string): string {
|
||||||
return xml.replace(CELL_BLOCK, (block, attrText: string, body?: string) => {
|
return xml.replace(CELL_BLOCK, (block, attrText: string, body?: string) => {
|
||||||
if (body === undefined) return block
|
if (body === undefined) return block
|
||||||
const attrs = readAttributes(attrText)
|
const attrs = readAttributes(attrText)
|
||||||
@@ -174,7 +174,7 @@ export function foldCells(xml: string, layer = "1"): string {
|
|||||||
(a) =>
|
(a) =>
|
||||||
a.name !== "vertex" &&
|
a.name !== "vertex" &&
|
||||||
a.name !== "edge" &&
|
a.name !== "edge" &&
|
||||||
!(a.name === "parent" && a.value === layer),
|
!(a.name === "parent" && a.value === "1"),
|
||||||
)
|
)
|
||||||
if (byName.get("vertex") === "1" && byName.get("edge") !== "1") {
|
if (byName.get("vertex") === "1" && byName.get("edge") !== "1") {
|
||||||
if (geometryKeys.join() !== "height,width,x,y") return block
|
if (geometryKeys.join() !== "height,width,x,y") return block
|
||||||
|
|||||||
@@ -79,6 +79,32 @@ function cellAttr(cell: Element, name: string): string | null {
|
|||||||
return inner?.getAttribute(name) ?? null
|
return inner?.getAttribute(name) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cell an add or update operation sends in new_xml, imported into doc,
|
||||||
|
* or the error message for the model.
|
||||||
|
*/
|
||||||
|
function parseNewCell(
|
||||||
|
op: DiagramOperation,
|
||||||
|
parser: DOMParser,
|
||||||
|
doc: Document,
|
||||||
|
): Element | string {
|
||||||
|
if (!op.new_xml) return `new_xml is required for ${op.operation} operation`
|
||||||
|
// A cut-off cell (its XML still streaming in a preview) would parse in
|
||||||
|
// Chrome to a cell without its geometry
|
||||||
|
const wrapped = `<wrapper>${op.new_xml}</wrapper>`
|
||||||
|
const syntaxError = getXmlSyntaxError(wrapped)
|
||||||
|
if (syntaxError) return `new_xml is not well-formed XML: ${syntaxError}`
|
||||||
|
const newCell = parser
|
||||||
|
.parseFromString(wrapped, "text/xml")
|
||||||
|
.querySelector(CELL_SELECTOR)
|
||||||
|
if (!newCell) return "new_xml must contain an mxCell element"
|
||||||
|
const newCellId = newCell.getAttribute("id")
|
||||||
|
if (newCellId !== op.cell_id) {
|
||||||
|
return `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`
|
||||||
|
}
|
||||||
|
return doc.importNode(newCell, true)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply diagram operations (update/add/delete) using ID-based lookup.
|
* Apply diagram operations (update/add/delete) using ID-based lookup.
|
||||||
*
|
*
|
||||||
@@ -207,63 +233,19 @@ export function applyDiagramOperations(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!op.new_xml) {
|
const newCell = parseNewCell(op, parser, doc)
|
||||||
|
if (typeof newCell === "string") {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "update",
|
type: "update",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: "new_xml is required for update operation",
|
message: newCell,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// A cut-off cell (its XML still streaming in a preview) would
|
existingCell.parentNode?.replaceChild(newCell, existingCell)
|
||||||
// parse in Chrome to a cell without its geometry
|
cellMap.set(op.cell_id, newCell)
|
||||||
const syntaxError = getXmlSyntaxError(
|
|
||||||
`<wrapper>${op.new_xml}</wrapper>`,
|
|
||||||
)
|
|
||||||
if (syntaxError) {
|
|
||||||
errors.push({
|
|
||||||
type: "update",
|
|
||||||
cellId: op.cell_id,
|
|
||||||
message: `new_xml is not well-formed XML: ${syntaxError}`,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the new XML
|
|
||||||
const newDoc = parser.parseFromString(
|
|
||||||
`<wrapper>${op.new_xml}</wrapper>`,
|
|
||||||
"text/xml",
|
|
||||||
)
|
|
||||||
const newCell = newDoc.querySelector(CELL_SELECTOR)
|
|
||||||
if (!newCell) {
|
|
||||||
errors.push({
|
|
||||||
type: "update",
|
|
||||||
cellId: op.cell_id,
|
|
||||||
message: "new_xml must contain an mxCell element",
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate ID matches
|
|
||||||
const newCellId = newCell.getAttribute("id")
|
|
||||||
if (newCellId !== op.cell_id) {
|
|
||||||
errors.push({
|
|
||||||
type: "update",
|
|
||||||
cellId: op.cell_id,
|
|
||||||
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import and replace the node
|
|
||||||
const importedNode = doc.importNode(newCell, true)
|
|
||||||
existingCell.parentNode?.replaceChild(importedNode, existingCell)
|
|
||||||
|
|
||||||
// Update the map with the new element
|
|
||||||
cellMap.set(op.cell_id, importedNode)
|
|
||||||
} else if (op.operation === "add") {
|
} else if (op.operation === "add") {
|
||||||
// Check if ID already exists
|
|
||||||
if (cellMap.has(op.cell_id)) {
|
if (cellMap.has(op.cell_id)) {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "add",
|
type: "add",
|
||||||
@@ -273,61 +255,18 @@ export function applyDiagramOperations(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!op.new_xml) {
|
const newCell = parseNewCell(op, parser, doc)
|
||||||
|
if (typeof newCell === "string") {
|
||||||
errors.push({
|
errors.push({
|
||||||
type: "add",
|
type: "add",
|
||||||
cellId: op.cell_id,
|
cellId: op.cell_id,
|
||||||
message: "new_xml is required for add operation",
|
message: newCell,
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// A cut-off cell (its XML still streaming in a preview) would
|
root.appendChild(newCell)
|
||||||
// parse in Chrome to a cell without its geometry
|
cellMap.set(op.cell_id, newCell)
|
||||||
const syntaxError = getXmlSyntaxError(
|
|
||||||
`<wrapper>${op.new_xml}</wrapper>`,
|
|
||||||
)
|
|
||||||
if (syntaxError) {
|
|
||||||
errors.push({
|
|
||||||
type: "add",
|
|
||||||
cellId: op.cell_id,
|
|
||||||
message: `new_xml is not well-formed XML: ${syntaxError}`,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the new XML
|
|
||||||
const newDoc = parser.parseFromString(
|
|
||||||
`<wrapper>${op.new_xml}</wrapper>`,
|
|
||||||
"text/xml",
|
|
||||||
)
|
|
||||||
const newCell = newDoc.querySelector(CELL_SELECTOR)
|
|
||||||
if (!newCell) {
|
|
||||||
errors.push({
|
|
||||||
type: "add",
|
|
||||||
cellId: op.cell_id,
|
|
||||||
message: "new_xml must contain an mxCell element",
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate ID matches
|
|
||||||
const newCellId = newCell.getAttribute("id")
|
|
||||||
if (newCellId !== op.cell_id) {
|
|
||||||
errors.push({
|
|
||||||
type: "add",
|
|
||||||
cellId: op.cell_id,
|
|
||||||
message: `ID mismatch: cell_id is "${op.cell_id}" but new_xml has id="${newCellId}"`,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import and append the node
|
|
||||||
const importedNode = doc.importNode(newCell, true)
|
|
||||||
root.appendChild(importedNode)
|
|
||||||
|
|
||||||
// Add to map
|
|
||||||
cellMap.set(op.cell_id, importedNode)
|
|
||||||
} else if (op.operation === "delete") {
|
} else if (op.operation === "delete") {
|
||||||
// Protect root cells from deletion
|
// Protect root cells from deletion
|
||||||
if (op.cell_id === "0" || op.cell_id === "1") {
|
if (op.cell_id === "0" || op.cell_id === "1") {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const escapeChars = (text: string, chars: RegExp) =>
|
|||||||
* Serialize a linkedom node as XML. Attribute values escape tabs and line
|
* Serialize a linkedom node as XML. Attribute values escape tabs and line
|
||||||
* breaks too, so multi-line labels (value="a
b") survive a round trip.
|
* breaks too, so multi-line labels (value="a
b") survive a round trip.
|
||||||
*/
|
*/
|
||||||
export function serializeXml(node: Node): string {
|
function serializeXml(node: Node): string {
|
||||||
switch (node.nodeType) {
|
switch (node.nodeType) {
|
||||||
case 9: {
|
case 9: {
|
||||||
// Document
|
// Document
|
||||||
|
|||||||
@@ -2,19 +2,23 @@
|
|||||||
* Drawing guide for the model, returned by start_session, get_drawing_guide
|
* Drawing guide for the model, returned by start_session, get_drawing_guide
|
||||||
* and the diagram-workflow prompt.
|
* and the diagram-workflow prompt.
|
||||||
*
|
*
|
||||||
* Adapted from the web app's system prompt (lib/system-prompts.ts) and its
|
* The XML examples and the style rules are shared with the web app's system
|
||||||
* tool descriptions (app/api/chat/route.ts). When drawing rules change there,
|
* prompt (xml-examples.ts). The other rules are adapted from that prompt
|
||||||
* update this file too.
|
* (lib/system-prompts.ts) and the web app's tool descriptions
|
||||||
|
* (app/api/chat/route.ts); when they change there, update this file too.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
indent,
|
indent,
|
||||||
STYLE_CLASS_EXAMPLE,
|
STYLE_CLASS_EXAMPLE,
|
||||||
SWIMLANE_EXAMPLE,
|
SWIMLANE_EXAMPLE,
|
||||||
|
styleRules,
|
||||||
TWO_EDGES_EXAMPLE,
|
TWO_EDGES_EXAMPLE,
|
||||||
WAYPOINT_EXAMPLE,
|
WAYPOINT_EXAMPLE,
|
||||||
} from "./xml-examples.ts"
|
} from "./xml-examples.ts"
|
||||||
|
|
||||||
|
const styles = styleRules("create_new_diagram or add_page", "the server")
|
||||||
|
|
||||||
export const DRAWING_GUIDE = `# Draw.io drawing guide
|
export const DRAWING_GUIDE = `# Draw.io drawing guide
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
@@ -84,12 +88,12 @@ Waypoints go inside <Array as="points"> in the edge geometry. Example: Hotfix (r
|
|||||||
${indent(WAYPOINT_EXAMPLE)}
|
${indent(WAYPOINT_EXAMPLE)}
|
||||||
|
|
||||||
## Styles
|
## Styles
|
||||||
Define each style used by several cells ONCE, as a named style before the cells, and use the name in the cells like a CSS class. A cell's style can combine a shape token, a name and overrides; later pairs win. Name only styles that two or more cells share; a style used by one cell stays inline. Names must not be draw.io's own style names: shapes such as text, ellipse, rhombus, swimlane, label, image, and colors such as blue, green, red, gray, yellow, orange, purple, pink. A definition applies to the call it is in: each create_new_diagram or add_page call defines the names it uses. The server expands the names, so the saved file is standard draw.io XML.
|
${styles.namedStyles}
|
||||||
|
|
||||||
${indent(STYLE_CLASS_EXAMPLE)}
|
${indent(STYLE_CLASS_EXAMPLE)}
|
||||||
|
|
||||||
- NEVER write html=1 or whiteSpace=wrap: the server adds html=1 to every cell and whiteSpace=wrap to shapes. Labels are HTML: use <br> for a line break and <b> for bold, never \\n; a literal < or > in a label is written &lt; or &gt;.
|
- ${styles.noHtml}
|
||||||
- Do NOT repeat what draw.io already uses. For a plain shape: rounded=0, align=center, verticalAlign=middle, fontSize=12, strokeWidth=1, fillColor=#ffffff, strokeColor=#000000, fontColor=#000000. For an edge: endArrow=classic, strokeColor=#000000. Writing one of them is right only when it overrides what a name or the shape sets: an edge is rounded by default, so rounded=0 on an edge is a real setting, and a text cell is left/top aligned by default, so there align=center or verticalAlign=middle are real settings.
|
- ${styles.noDefaults}
|
||||||
- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex
|
- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex
|
||||||
- Edges: endArrow=block, open or none; startArrow=classic; curved=1; dashed=1; edgeStyle=orthogonalEdgeStyle
|
- Edges: endArrow=block, open or none; startArrow=classic; curved=1; dashed=1; edgeStyle=orthogonalEdgeStyle
|
||||||
- Text: fontSize=14, fontStyle=1 (bold), align=center or right
|
- Text: fontSize=14, fontStyle=1 (bold), align=center or right
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const DRAWIO_THEMES = [
|
const DRAWIO_THEMES = [
|
||||||
"kennedy",
|
"kennedy",
|
||||||
"atlas",
|
"atlas",
|
||||||
"dark",
|
"dark",
|
||||||
@@ -7,7 +7,7 @@ export const DRAWIO_THEMES = [
|
|||||||
"simple",
|
"simple",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
|
type DrawioTheme = (typeof DRAWIO_THEMES)[number]
|
||||||
|
|
||||||
export function isDrawioTheme(value: unknown): value is DrawioTheme {
|
export function isDrawioTheme(value: unknown): value is DrawioTheme {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface HistoryEntry {
|
|||||||
let nextEntryId = 0
|
let nextEntryId = 0
|
||||||
const historyStore = new Map<string, HistoryEntry[]>()
|
const historyStore = new Map<string, HistoryEntry[]>()
|
||||||
|
|
||||||
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
export function addHistory(sessionId: string, xml: string, svg = ""): void {
|
||||||
let history = historyStore.get(sessionId)
|
let history = historyStore.get(sessionId)
|
||||||
if (!history) {
|
if (!history) {
|
||||||
history = []
|
history = []
|
||||||
@@ -31,7 +31,7 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
|||||||
const last = history[history.length - 1]
|
const last = history[history.length - 1]
|
||||||
if (last && last.xml === xml) {
|
if (last && last.xml === xml) {
|
||||||
if (svg && !last.svg) last.svg = svg
|
if (svg && !last.svg) last.svg = svg
|
||||||
return history.length - 1
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
history.push({ id: nextEntryId++, xml, svg })
|
history.push({ id: nextEntryId++, xml, svg })
|
||||||
@@ -42,7 +42,6 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.debug(`History: session=${sessionId}, entries=${history.length}`)
|
log.debug(`History: session=${sessionId}, entries=${history.length}`)
|
||||||
return history.length - 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getHistory(sessionId: string): HistoryEntry[] {
|
export function getHistory(sessionId: string): HistoryEntry[] {
|
||||||
|
|||||||
@@ -146,13 +146,18 @@ interface SessionState {
|
|||||||
lastPolled?: number // Last browser poll; an open tab keeps the session alive
|
lastPolled?: number // Last browser poll; an open tab keeps the session alive
|
||||||
svg?: string // Cached SVG from last browser save
|
svg?: string // Cached SVG from last browser save
|
||||||
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
||||||
exportFormat?: ExportFormat // Set by MCP tool to request browser export
|
pendingExport?: PendingExport // Set by MCP tool to request browser export
|
||||||
exportXml?: string // Single-page projection to load before a page-targeted export
|
|
||||||
exportOptions?: ExportOptions // Extra draw.io export parameters (PNG only)
|
|
||||||
exportId?: number // Number of the pending export, echoed with its result
|
|
||||||
exportData?: string // Base64/SVG data returned by browser after export
|
exportData?: string // Base64/SVG data returned by browser after export
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An export the browser is asked to make, sent to the preview page as is */
|
||||||
|
interface PendingExport {
|
||||||
|
id: number // Number of the export, echoed with its result
|
||||||
|
format: ExportFormat
|
||||||
|
xml?: string // Single-page projection to load before a page-targeted export
|
||||||
|
options?: ExportOptions // Extra draw.io export parameters (PNG only)
|
||||||
|
}
|
||||||
|
|
||||||
/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */
|
/** draw.io export formats; xmlsvg is an SVG with the diagram embedded */
|
||||||
export type ExportFormat = "png" | "svg" | "xmlsvg"
|
export type ExportFormat = "png" | "svg" | "xmlsvg"
|
||||||
|
|
||||||
@@ -165,7 +170,7 @@ export interface ExportOptions {
|
|||||||
pageId?: string
|
pageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stateStore = new Map<string, SessionState>()
|
const stateStore = new Map<string, SessionState>()
|
||||||
|
|
||||||
let server: http.Server | null = null
|
let server: http.Server | null = null
|
||||||
let serverPort = 6002
|
let serverPort = 6002
|
||||||
@@ -220,10 +225,7 @@ export function setState(
|
|||||||
svg ||
|
svg ||
|
||||||
(existing && existing.xml === xml ? existing.svg : undefined),
|
(existing && existing.xml === xml ? existing.svg : undefined),
|
||||||
syncRequested: undefined, // Clear sync request when browser pushes state
|
syncRequested: undefined, // Clear sync request when browser pushes state
|
||||||
exportFormat: existing?.exportFormat, // Preserve pending export request
|
pendingExport: existing?.pendingExport, // Preserve pending export request
|
||||||
exportXml: existing?.exportXml, // Preserve pending projection
|
|
||||||
exportOptions: existing?.exportOptions,
|
|
||||||
exportId: existing?.exportId,
|
|
||||||
exportData: existing?.exportData, // Preserve export result
|
exportData: existing?.exportData, // Preserve export result
|
||||||
})
|
})
|
||||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||||
@@ -266,12 +268,14 @@ export function requestExport(
|
|||||||
const state = stateStore.get(sessionId)
|
const state = stateStore.get(sessionId)
|
||||||
if (!state) return false
|
if (!state) return false
|
||||||
state.exportData = undefined
|
state.exportData = undefined
|
||||||
state.exportXml = projectionXml
|
// The browser sends the id back with the result, so a late result of an
|
||||||
state.exportOptions = options
|
|
||||||
state.exportFormat = format
|
|
||||||
// The browser sends this back with the result, so a late result of an
|
|
||||||
// export that timed out is not taken for this one
|
// export that timed out is not taken for this one
|
||||||
state.exportId = ++lastExportId
|
state.pendingExport = {
|
||||||
|
id: ++lastExportId,
|
||||||
|
format,
|
||||||
|
xml: projectionXml,
|
||||||
|
options,
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +344,7 @@ export function startHttpServer(port = 6002): Promise<number> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopHttpServer(): void {
|
function stopHttpServer(): void {
|
||||||
if (server) {
|
if (server) {
|
||||||
server.close()
|
server.close()
|
||||||
server = null
|
server = null
|
||||||
@@ -421,19 +425,6 @@ function routeRequest(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestOrigin = req.headers.origin
|
|
||||||
if (requestOrigin === `http://localhost:${serverPort}`) {
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", requestOrigin)
|
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
res.writeHead(204)
|
|
||||||
res.end()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname === "/" || url.pathname === "/index.html") {
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
||||||
const sessionId = url.searchParams.get("mcp") || ""
|
const sessionId = url.searchParams.get("mcp") || ""
|
||||||
if (sessionId && !isValidSessionId(sessionId)) {
|
if (sessionId && !isValidSessionId(sessionId)) {
|
||||||
@@ -497,10 +488,7 @@ function handleStateApi(
|
|||||||
stateId: state?.stateId ?? null,
|
stateId: state?.stateId ?? null,
|
||||||
blank: !!state?.blank,
|
blank: !!state?.blank,
|
||||||
syncRequested: !!state?.syncRequested,
|
syncRequested: !!state?.syncRequested,
|
||||||
exportFormat: state?.exportFormat || null,
|
export: state?.pendingExport ?? null,
|
||||||
exportXml: state?.exportXml || null,
|
|
||||||
exportOptions: state?.exportOptions || null,
|
|
||||||
exportId: state?.exportId ?? null,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else if (req.method === "POST") {
|
} else if (req.method === "POST") {
|
||||||
@@ -519,12 +507,9 @@ function handleStateApi(
|
|||||||
// Browser is returning export data (png/svg)
|
// Browser is returning export data (png/svg)
|
||||||
if (data.exportData !== undefined) {
|
if (data.exportData !== undefined) {
|
||||||
const state = stateStore.get(sessionId)
|
const state = stateStore.get(sessionId)
|
||||||
if (state && data.exportId === state.exportId) {
|
if (state && data.exportId === state.pendingExport?.id) {
|
||||||
state.exportData = data.exportData
|
state.exportData = data.exportData
|
||||||
state.exportFormat = undefined
|
state.pendingExport = undefined
|
||||||
state.exportXml = undefined
|
|
||||||
state.exportOptions = undefined
|
|
||||||
state.exportId = undefined
|
|
||||||
log.debug(
|
log.debug(
|
||||||
`Export data received for session=${sessionId}`,
|
`Export data received for session=${sessionId}`,
|
||||||
)
|
)
|
||||||
@@ -554,11 +539,7 @@ function handleStateApi(
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
})
|
})
|
||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({ error: "Session was recreated" }),
|
||||||
error: "Session was recreated",
|
|
||||||
stateChanged: true,
|
|
||||||
version: current.version,
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -579,7 +560,6 @@ function handleStateApi(
|
|||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Diagram changed on the server",
|
error: "Diagram changed on the server",
|
||||||
version: current.version,
|
|
||||||
savedToHistory: saved,
|
savedToHistory: saved,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -614,7 +594,6 @@ function handleStateApi(
|
|||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: "Diagram changed on the server",
|
error: "Diagram changed on the server",
|
||||||
version: current?.version,
|
|
||||||
savedToHistory,
|
savedToHistory,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -669,7 +648,6 @@ function handleHistoryApi(
|
|||||||
id: entry.id,
|
id: entry.id,
|
||||||
svg: entry.svg,
|
svg: entry.svg,
|
||||||
})),
|
})),
|
||||||
count: history.length,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -737,15 +715,14 @@ function handleRestoreApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const newVersion = restoreHistoryEntry(sessionId, id)
|
if (restoreHistoryEntry(sessionId, id) === null) {
|
||||||
if (newVersion === null) {
|
|
||||||
res.writeHead(404, { "Content-Type": "application/json" })
|
res.writeHead(404, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ error: "Entry not found" }))
|
res.end(JSON.stringify({ error: "Entry not found" }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ success: true, newVersion }))
|
res.end(JSON.stringify({ success: true }))
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(400, { "Content-Type": "application/json" })
|
res.writeHead(400, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
res.end(JSON.stringify({ error: "Invalid JSON" }))
|
||||||
|
|||||||
+799
-1429
File diff suppressed because it is too large
Load Diff
@@ -121,21 +121,12 @@ export function truncatedCellError(input: string): string | null {
|
|||||||
return `The XML ends inside an unfinished mxCell, so the output was probably cut off. Nothing was drawn. It ended with:\n${input.slice(-300)}\nResend the complete diagram, or send the first cells now and add the remaining cells with edit_diagram add operations.`
|
return `The XML ends inside an unfinished mxCell, so the output was probably cut off. Nothing was drawn. It ended with:\n${input.slice(-300)}\nResend the complete diagram, or send the first cells now and add the remaining cells with edit_diagram add operations.`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The named style definitions are taken out first (style-classes.ts). Bare
|
|
||||||
* cells then get the wrapper and root cells, since the strict parser rejects
|
|
||||||
* several top-level elements. Then the XML is validated and auto-fixed while
|
|
||||||
* it is still a bare model, where duplicate ids are renamed. Compact cells
|
|
||||||
* (compact-cells.ts) are expanded, the names expanded and the default styles
|
|
||||||
* added on the fixed XML, so repaired cells get them too, and finally it is
|
|
||||||
* turned into an <mxfile>.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Take the named style definitions out of the model's XML (style-classes.ts).
|
* Take the named style definitions out of the model's XML (style-classes.ts).
|
||||||
* Returns the error for the model when a definition never closed or when
|
* Returns the error for the model when a definition never closed or when
|
||||||
* nothing but definitions was sent.
|
* nothing but definitions was sent.
|
||||||
*/
|
*/
|
||||||
export function takeStyleDefinitions(input: string): {
|
function takeStyleDefinitions(input: string): {
|
||||||
classes: StyleClasses
|
classes: StyleClasses
|
||||||
xml: string
|
xml: string
|
||||||
error: string | null
|
error: string | null
|
||||||
@@ -152,10 +143,17 @@ export function takeStyleDefinitions(input: string): {
|
|||||||
return { classes, xml, error }
|
return { classes, xml, error }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareNewDiagram(
|
/**
|
||||||
input: string,
|
* The model of one page from the XML the model wrote (also the starting
|
||||||
page: { pageId?: string; pageName?: string } = {},
|
* content of add_page). The named style definitions are taken out first
|
||||||
): NewDiagram {
|
* (style-classes.ts). Bare cells then get the wrapper and root cells, since
|
||||||
|
* the strict parser rejects several top-level elements. Then the XML is
|
||||||
|
* validated and auto-fixed while it is still a bare model, where duplicate
|
||||||
|
* ids are renamed. Compact cells (compact-cells.ts) are expanded, the names
|
||||||
|
* expanded and the default styles added on the fixed XML, so repaired cells
|
||||||
|
* get them too.
|
||||||
|
*/
|
||||||
|
export function prepareNewModel(input: string): NewDiagram {
|
||||||
const {
|
const {
|
||||||
classes,
|
classes,
|
||||||
xml: cells,
|
xml: cells,
|
||||||
@@ -182,12 +180,22 @@ export function prepareNewDiagram(
|
|||||||
error: `XML validation failed after expanding the cells - ${rewriteError}`,
|
error: `XML validation failed after expanding the cells - ${rewriteError}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const normalized = normalizeToMxfile(xml, page)
|
return { ok: true, xml, fixes }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** prepareNewModel, and the model turned into an <mxfile> */
|
||||||
|
export function prepareNewDiagram(
|
||||||
|
input: string,
|
||||||
|
page: { pageId?: string; pageName?: string } = {},
|
||||||
|
): NewDiagram {
|
||||||
|
const model = prepareNewModel(input)
|
||||||
|
if (!model.ok) return model
|
||||||
|
const normalized = normalizeToMxfile(model.xml, page)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "XML must be the mxCell elements of one page, a <mxGraphModel>, or an <mxfile> with one or more <diagram> children.",
|
error: "XML must be the mxCell elements of one page, a <mxGraphModel>, or an <mxfile> with one or more <diagram> children.",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ok: true, xml: normalized, fixes }
|
return { ok: true, xml: normalized, fixes: model.fixes }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function wrapCellsInModel(xml: string): string {
|
|||||||
*/
|
*/
|
||||||
export function normalizeToMxfile(
|
export function normalizeToMxfile(
|
||||||
xml: string,
|
xml: string,
|
||||||
opts: { pageId?: string; pageName?: string; host?: string } = {},
|
opts: { pageId?: string; pageName?: string } = {},
|
||||||
): string | null {
|
): string | null {
|
||||||
const trimmed = xml.trim()
|
const trimmed = xml.trim()
|
||||||
if (!trimmed) return null
|
if (!trimmed) return null
|
||||||
@@ -156,9 +156,8 @@ export function normalizeToMxfile(
|
|||||||
|
|
||||||
const pageId = opts.pageId || generatePageId()
|
const pageId = opts.pageId || generatePageId()
|
||||||
const pageName = opts.pageName || "Page-1"
|
const pageName = opts.pageName || "Page-1"
|
||||||
const host = opts.host || "app.diagrams.net"
|
|
||||||
const inner = stripXmlDeclaration(trimmed)
|
const inner = stripXmlDeclaration(trimmed)
|
||||||
return `<mxfile host="${escapeAttr(host)}"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>`
|
return `<mxfile host="app.diagrams.net"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ html, body { width: 100%; height: 100%; overflow: hidden; }
|
|||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
#header .logo img { width: 20px; height: 20px; filter: brightness(0) invert(1); }
|
|
||||||
#header .title {
|
#header .title {
|
||||||
font-size: 15px; font-weight: 600; color: #1a1a2e;
|
font-size: 15px; font-weight: 600; color: #1a1a2e;
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
|
|||||||
@@ -328,17 +328,17 @@ async function poll() {
|
|||||||
// Plain export: capture whatever tab is currently displayed.
|
// Plain export: capture whatever tab is currently displayed.
|
||||||
//
|
//
|
||||||
// Page-targeted export: the server sends a single-page <mxfile>
|
// Page-targeted export: the server sends a single-page <mxfile>
|
||||||
// projection in s.exportXml. We load it into the iframe, let
|
// projection in s.export.xml. We load it into the iframe, let
|
||||||
// draw.io render it, export, then reload the user's real
|
// draw.io render it, export, then reload the user's real
|
||||||
// document — all browser-side. The canonical session state is
|
// document — all browser-side. The canonical session state is
|
||||||
// never mutated, so there is no server-side restore race and no
|
// never mutated, so there is no server-side restore race and no
|
||||||
// dependence on poll timing. autosave is suppressed while the
|
// dependence on poll timing. autosave is suppressed while the
|
||||||
// projection is showing (see projectionExportActive guard).
|
// projection is showing (see projectionExportActive guard).
|
||||||
if (s.exportFormat && !pendingMcpExport && isReady) {
|
if (s.export && !pendingMcpExport && isReady) {
|
||||||
pendingMcpExport = s.exportFormat;
|
pendingMcpExport = s.export.format;
|
||||||
const seq = ++mcpExportSeq;
|
const seq = ++mcpExportSeq;
|
||||||
mcpExportId = s.exportId;
|
mcpExportId = s.export.id;
|
||||||
const extra = s.exportOptions || {};
|
const extra = s.export.options || {};
|
||||||
const fireExport = () => {
|
const fireExport = () => {
|
||||||
// mcpExport carries this export's number and is echoed
|
// mcpExport carries this export's number and is echoed
|
||||||
// back in msg.message (see the handler). PNG: width
|
// back in msg.message (see the handler). PNG: width
|
||||||
@@ -349,10 +349,10 @@ async function poll() {
|
|||||||
: { action: 'export', format: pendingMcpExport, mcpExport: seq };
|
: { action: 'export', format: pendingMcpExport, mcpExport: seq };
|
||||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||||
};
|
};
|
||||||
if (s.exportXml) {
|
if (s.export.xml) {
|
||||||
projectionExportActive = true;
|
projectionExportActive = true;
|
||||||
// Load the projection without touching lastXml/server state.
|
// Load the projection without touching lastXml/server state.
|
||||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*');
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.export.xml, autosave: 0 }), '*');
|
||||||
// Let draw.io render the loaded page before exporting
|
// Let draw.io render the loaded page before exporting
|
||||||
// (same proven settle delay as the AI-preview path).
|
// (same proven settle delay as the AI-preview path).
|
||||||
setTimeout(fireExport, 600);
|
setTimeout(fireExport, 600);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { readFile } from "node:fs/promises"
|
|||||||
import { dirname, join, resolve } from "node:path"
|
import { dirname, join, resolve } from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
export const SHAPE_LIBRARY_GROUPS: Record<string, string[]> = {
|
const SHAPE_LIBRARY_GROUPS: Record<string, string[]> = {
|
||||||
Cloud: [
|
Cloud: [
|
||||||
"aws4",
|
"aws4",
|
||||||
"azure2",
|
"azure2",
|
||||||
|
|||||||
@@ -224,9 +224,3 @@ export function addDefaultStyles(
|
|||||||
return `${current}${separator}${added.join(";")};`
|
return `${current}${separator}${added.join(";")};`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Expand the named styles of a model's XML and add the default styles */
|
|
||||||
export function expandStyles(xml: string): string {
|
|
||||||
const { classes, xml: cells } = readStyleClasses(xml)
|
|
||||||
return addDefaultStyles(applyStyleClasses(cells, classes))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ export const STYLE_CLASS_EXAMPLE = `<mxStyle name="step" value="fillColor=#dae8f
|
|||||||
<mxCell id="5" style="down;" source="2" target="3"/>
|
<mxCell id="5" style="down;" source="2" target="3"/>
|
||||||
<mxCell id="6" style="down;" source="3" target="4"/>`
|
<mxCell id="6" style="down;" source="3" target="4"/>`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The style rules shared by the drawing guide and the web app's system
|
||||||
|
* prompt. They differ only in the tool whose call defines named styles
|
||||||
|
* ("create_new_diagram or add_page", "display_diagram") and in who expands
|
||||||
|
* them ("the server", "the app").
|
||||||
|
*/
|
||||||
|
export function styleRules(tool: string, actor: string) {
|
||||||
|
const actorSentence = actor.charAt(0).toUpperCase() + actor.slice(1)
|
||||||
|
return {
|
||||||
|
/** Named styles, defined once and used like CSS classes */
|
||||||
|
namedStyles: `Define each style used by several cells ONCE, as a named style before the cells, and use the name in the cells like a CSS class. A cell's style can combine a shape token, a name and overrides; later pairs win. Name only styles that two or more cells share; a style used by one cell stays inline. Names must not be draw.io's own style names: shapes such as text, ellipse, rhombus, swimlane, label, image, and colors such as blue, green, red, gray, yellow, orange, purple, pink. A definition applies to the call it is in: each ${tool} call defines the names it uses. ${actorSentence} expands the names, so the saved file is standard draw.io XML.`,
|
||||||
|
/** html=1 and whiteSpace=wrap are added automatically */
|
||||||
|
noHtml: `NEVER write html=1 or whiteSpace=wrap: ${actor} adds html=1 to every cell and whiteSpace=wrap to shapes. Labels are HTML: use <br> for a line break and <b> for bold, never \\n; a literal < or > in a label is written &lt; or &gt;.`,
|
||||||
|
/** draw.io's own defaults are not written */
|
||||||
|
noDefaults: `Do NOT repeat what draw.io already uses. For a plain shape: rounded=0, align=center, verticalAlign=middle, fontSize=12, strokeWidth=1, fillColor=#ffffff, strokeColor=#000000, fontColor=#000000. For an edge: endArrow=classic, strokeColor=#000000. Writing one of them is right only when it overrides what a name or the shape sets: an edge is rounded by default, so rounded=0 on an edge is a real setting, and a text cell is left/top aligned by default, so there align=center or verticalAlign=middle are real settings.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Indent every line, for an indented code block in Markdown */
|
/** Indent every line, for an indented code block in Markdown */
|
||||||
export const indent = (text: string, prefix = " ") =>
|
export const indent = (text: string, prefix = " ") =>
|
||||||
text
|
text
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* XML Validation and Auto-Fix for draw.io diagrams
|
* XML validation and auto-fix for draw.io diagrams, shared by the MCP server
|
||||||
* Copied from lib/utils.ts to avoid cross-package imports
|
* and the web app (contexts/diagram-context.tsx)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readAttributes } from "./xml-attributes.ts"
|
import { readAttributes } from "./xml-attributes.ts"
|
||||||
@@ -10,9 +10,6 @@ import { getXmlSyntaxError } from "./xml-syntax.ts"
|
|||||||
// Constants
|
// Constants
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/** Maximum XML size to process (1MB) - larger XMLs may cause performance issues */
|
|
||||||
const MAX_XML_SIZE = 1_000_000
|
|
||||||
|
|
||||||
/** Structural attributes that should not be duplicated in draw.io */
|
/** Structural attributes that should not be duplicated in draw.io */
|
||||||
const STRUCTURAL_ATTRS = [
|
const STRUCTURAL_ATTRS = [
|
||||||
"edge",
|
"edge",
|
||||||
@@ -23,9 +20,6 @@ const STRUCTURAL_ATTRS = [
|
|||||||
"connectable",
|
"connectable",
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Valid XML entity names */
|
|
||||||
const VALID_ENTITIES = new Set(["lt", "gt", "amp", "quot", "apos"])
|
|
||||||
|
|
||||||
/** Element names draw.io understands (case-sensitive) */
|
/** Element names draw.io understands (case-sensitive) */
|
||||||
const VALID_DRAWIO_TAGS = new Set([
|
const VALID_DRAWIO_TAGS = new Set([
|
||||||
"mxfile",
|
"mxfile",
|
||||||
@@ -261,77 +255,6 @@ function checkDuplicateIds(xml: string): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check for tag mismatches using parsed tags */
|
|
||||||
function checkTagMismatches(xml: string): string | null {
|
|
||||||
const xmlWithoutComments = xml.replace(/<!--[\s\S]*?-->/g, "")
|
|
||||||
const tags = parseXmlTags(xmlWithoutComments)
|
|
||||||
const tagStack: string[] = []
|
|
||||||
|
|
||||||
for (const { tagName, isClosing, isSelfClosing } of tags) {
|
|
||||||
if (isClosing) {
|
|
||||||
if (tagStack.length === 0) {
|
|
||||||
return `Invalid XML: Closing tag </${tagName}> without matching opening tag`
|
|
||||||
}
|
|
||||||
const expected = tagStack.pop()
|
|
||||||
if (expected?.toLowerCase() !== tagName.toLowerCase()) {
|
|
||||||
return `Invalid XML: Expected closing tag </${expected}> but found </${tagName}>`
|
|
||||||
}
|
|
||||||
} else if (!isSelfClosing) {
|
|
||||||
tagStack.push(tagName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tagStack.length > 0) {
|
|
||||||
return `Invalid XML: Document has ${tagStack.length} unclosed tag(s): ${tagStack.join(", ")}`
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check for invalid character references */
|
|
||||||
function checkCharacterReferences(xml: string): string | null {
|
|
||||||
const charRefPattern = /&#x?[^;]+;?/g
|
|
||||||
let charMatch
|
|
||||||
while ((charMatch = charRefPattern.exec(xml)) !== null) {
|
|
||||||
const ref = charMatch[0]
|
|
||||||
if (ref.startsWith("&#x")) {
|
|
||||||
if (!ref.endsWith(";")) {
|
|
||||||
return `Invalid XML: Missing semicolon after hex reference: ${ref}`
|
|
||||||
}
|
|
||||||
const hexDigits = ref.substring(3, ref.length - 1)
|
|
||||||
if (hexDigits.length === 0 || !/^[0-9a-fA-F]+$/.test(hexDigits)) {
|
|
||||||
return `Invalid XML: Invalid hex character reference: ${ref}`
|
|
||||||
}
|
|
||||||
} else if (ref.startsWith("&#")) {
|
|
||||||
if (!ref.endsWith(";")) {
|
|
||||||
return `Invalid XML: Missing semicolon after decimal reference: ${ref}`
|
|
||||||
}
|
|
||||||
const decDigits = ref.substring(2, ref.length - 1)
|
|
||||||
if (decDigits.length === 0 || !/^[0-9]+$/.test(decDigits)) {
|
|
||||||
return `Invalid XML: Invalid decimal character reference: ${ref}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check for invalid entity references */
|
|
||||||
function checkEntityReferences(xml: string): string | null {
|
|
||||||
const xmlWithoutComments = xml.replace(/<!--[\s\S]*?-->/g, "")
|
|
||||||
const bareAmpPattern = /&(?!(?:lt|gt|amp|quot|apos|#))/g
|
|
||||||
if (bareAmpPattern.test(xmlWithoutComments)) {
|
|
||||||
return "Invalid XML: Found unescaped & character(s). Replace & with &"
|
|
||||||
}
|
|
||||||
const invalidEntityPattern = /&([a-zA-Z][a-zA-Z0-9]*);/g
|
|
||||||
let entityMatch
|
|
||||||
while (
|
|
||||||
(entityMatch = invalidEntityPattern.exec(xmlWithoutComments)) !== null
|
|
||||||
) {
|
|
||||||
if (!VALID_ENTITIES.has(entityMatch[1])) {
|
|
||||||
return `Invalid XML: Invalid entity reference: &${entityMatch[1]}; - use only valid XML entities (lt, gt, amp, quot, apos)`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check for nested mxCell tags using regex */
|
/** Check for nested mxCell tags using regex */
|
||||||
function checkNestedMxCells(xml: string): string | null {
|
function checkNestedMxCells(xml: string): string | null {
|
||||||
// Quoted values may hold ">", so the tag ends at the first ">" outside them
|
// Quoted values may hold ">", so the tag ends at the first ">" outside them
|
||||||
@@ -427,13 +350,6 @@ export function validateMxCellStructure(
|
|||||||
xml: string,
|
xml: string,
|
||||||
opts: { strict?: boolean } = {},
|
opts: { strict?: boolean } = {},
|
||||||
): string | null {
|
): string | null {
|
||||||
// Size check for performance
|
|
||||||
if (xml.length > MAX_XML_SIZE) {
|
|
||||||
console.warn(
|
|
||||||
`[validateMxCellStructure] XML size (${xml.length}) exceeds ${MAX_XML_SIZE} bytes, may cause performance issues`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 0. DOM-based checks. Syntax errors are caught by the strict check at
|
// 0. DOM-based checks. Syntax errors are caught by the strict check at
|
||||||
// the end: linkedom's DOMParser never reports them.
|
// the end: linkedom's DOMParser never reports them.
|
||||||
try {
|
try {
|
||||||
@@ -464,66 +380,24 @@ export function validateMxCellStructure(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Check for CDATA wrapper (invalid at document root)
|
// 1. Check for duplicate structural attributes
|
||||||
if (/^\s*<!\[CDATA\[/.test(xml)) {
|
|
||||||
return "Invalid XML: XML is wrapped in CDATA section - remove <![CDATA[ from start and ]]> from end"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check for duplicate structural attributes
|
|
||||||
const dupAttrError = checkDuplicateAttributes(xml)
|
const dupAttrError = checkDuplicateAttributes(xml)
|
||||||
if (dupAttrError) {
|
if (dupAttrError) {
|
||||||
return dupAttrError
|
return dupAttrError
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check for unescaped < in attribute values
|
// 2. Check for duplicate IDs
|
||||||
const attrValuePattern = /=\s*"([^"]*)"/g
|
|
||||||
let attrValMatch
|
|
||||||
while ((attrValMatch = attrValuePattern.exec(xml)) !== null) {
|
|
||||||
const value = attrValMatch[1]
|
|
||||||
if (/</.test(value) && !/</.test(value)) {
|
|
||||||
return "Invalid XML: Unescaped < character in attribute values. Replace < with <"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Check for duplicate IDs
|
|
||||||
const dupIdError = checkDuplicateIds(xml)
|
const dupIdError = checkDuplicateIds(xml)
|
||||||
if (dupIdError) {
|
if (dupIdError) {
|
||||||
return dupIdError
|
return dupIdError
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Check for tag mismatches
|
// 3. Check for empty id attributes on mxCell
|
||||||
const tagMismatchError = checkTagMismatches(xml)
|
|
||||||
if (tagMismatchError) {
|
|
||||||
return tagMismatchError
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Check invalid character references
|
|
||||||
const charRefError = checkCharacterReferences(xml)
|
|
||||||
if (charRefError) {
|
|
||||||
return charRefError
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Check for invalid comment syntax (-- inside comments)
|
|
||||||
const commentPattern = /<!--([\s\S]*?)-->/g
|
|
||||||
let commentMatch
|
|
||||||
while ((commentMatch = commentPattern.exec(xml)) !== null) {
|
|
||||||
if (/--/.test(commentMatch[1])) {
|
|
||||||
return "Invalid XML: Comment contains -- (double hyphen) which is not allowed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Check for unescaped entity references and invalid entity names
|
|
||||||
const entityError = checkEntityReferences(xml)
|
|
||||||
if (entityError) {
|
|
||||||
return entityError
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. Check for empty id attributes on mxCell
|
|
||||||
if (/<mxCell[^>]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) {
|
if (/<mxCell[^>]*\sid\s*=\s*["']\s*["'][^>]*>/g.test(xml)) {
|
||||||
return "Invalid XML: Found mxCell element(s) with empty id attribute"
|
return "Invalid XML: Found mxCell element(s) with empty id attribute"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Check for nested mxCell tags
|
// 4. Check for nested mxCell tags
|
||||||
const nestedCellError = checkNestedMxCells(xml)
|
const nestedCellError = checkNestedMxCells(xml)
|
||||||
if (nestedCellError) {
|
if (nestedCellError) {
|
||||||
return nestedCellError
|
return nestedCellError
|
||||||
@@ -539,9 +413,10 @@ export function validateMxCellStructure(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. Strict XML syntax check, run last so the checks above can give
|
// 5. Strict XML syntax check (saxes), which reports every
|
||||||
// more specific messages. Catches what they miss, e.g. duplicate or
|
// well-formedness error: unbalanced tags, bad character or entity
|
||||||
// unquoted attributes, which make draw.io refuse to load the diagram.
|
// references, an unescaped < or &, duplicate or unquoted attributes.
|
||||||
|
// Any of them makes draw.io refuse to load the diagram.
|
||||||
const syntaxError = getXmlSyntaxError(xml)
|
const syntaxError = getXmlSyntaxError(xml)
|
||||||
if (syntaxError) {
|
if (syntaxError) {
|
||||||
return `Invalid XML: syntax error at ${syntaxError} Escape special characters in attribute values (< for <, & for &, " for "), quote every attribute value, and do not repeat an attribute.`
|
return `Invalid XML: syntax error at ${syntaxError} Escape special characters in attribute values (< for <, & for &, " for "), quote every attribute value, and do not repeat an attribute.`
|
||||||
|
|||||||
@@ -5,13 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { deflateRawSync } from "node:zlib"
|
import { deflateRawSync } from "node:zlib"
|
||||||
import { beforeAll, describe, expect, it, vi } from "vitest"
|
import { describe, expect, it, vi } from "vitest"
|
||||||
import { installDomPolyfill } from "../src/dom.ts"
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
installDomPolyfill()
|
|
||||||
})
|
|
||||||
|
|
||||||
import { applyDiagramOperations } from "../src/diagram-operations.ts"
|
import { applyDiagramOperations } from "../src/diagram-operations.ts"
|
||||||
|
|
||||||
const DOC = `<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="a" label="A" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><mxCell id="b" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><object id="e1" label="" tooltip="t"><mxCell edge="1" source="b" target="a" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell></object><mxCell id="child" value="C" vertex="1" parent="a"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
const DOC = `<mxfile><diagram id="p" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="a" label="A" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><mxCell id="b" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><object id="e1" label="" tooltip="t"><mxCell edge="1" source="b" target="a" parent="1"><mxGeometry relative="1" as="geometry"/></mxCell></object><mxCell id="child" value="C" vertex="1" parent="a"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
||||||
|
|||||||
@@ -2,13 +2,7 @@
|
|||||||
* Tests for the all-or-nothing edit_diagram core (src/edit-diagram.ts).
|
* Tests for the all-or-nothing edit_diagram core (src/edit-diagram.ts).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import { installDomPolyfill } from "../src/dom.ts"
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
installDomPolyfill()
|
|
||||||
})
|
|
||||||
|
|
||||||
import { editDiagram, targetPageXml } from "../src/edit-diagram.ts"
|
import { editDiagram, targetPageXml } from "../src/edit-diagram.ts"
|
||||||
import { validateMxCellStructure } from "../src/xml-validation.ts"
|
import { validateMxCellStructure } from "../src/xml-validation.ts"
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,7 @@
|
|||||||
* reads as a user edit.
|
* reads as a user edit.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import { installDomPolyfill } from "../src/dom.ts"
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
installDomPolyfill()
|
|
||||||
})
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
checkEditGate,
|
checkEditGate,
|
||||||
contentFingerprint,
|
contentFingerprint,
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
* Tests for the diagram history: the versions restore_version can go to.
|
* Tests for the diagram history: the versions restore_version can go to.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import { installDomPolyfill } from "../src/dom.ts"
|
|
||||||
import {
|
import {
|
||||||
addHistory,
|
addHistory,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
@@ -12,11 +11,6 @@ import {
|
|||||||
otherVersions,
|
otherVersions,
|
||||||
} from "../src/history.ts"
|
} from "../src/history.ts"
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
// XML parsing for the content fingerprints
|
|
||||||
installDomPolyfill()
|
|
||||||
})
|
|
||||||
|
|
||||||
const page = (cellId: string) =>
|
const page = (cellId: string) =>
|
||||||
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||||
import { installDomPolyfill } from "../src/dom.ts"
|
|
||||||
import { addHistory, getHistory } from "../src/history.ts"
|
import { addHistory, getHistory } from "../src/history.ts"
|
||||||
import {
|
import {
|
||||||
drawioEmbedParams,
|
drawioEmbedParams,
|
||||||
@@ -28,8 +27,6 @@ import {
|
|||||||
let port = 0
|
let port = 0
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// XML parsing, as the server installs it at startup
|
|
||||||
installDomPolyfill()
|
|
||||||
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
|
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -330,15 +327,15 @@ describe("export requests", () => {
|
|||||||
const poll = JSON.parse(
|
const poll = JSON.parse(
|
||||||
(await request(`/api/state?sessionId=${id}`)).body,
|
(await request(`/api/state?sessionId=${id}`)).body,
|
||||||
)
|
)
|
||||||
expect(poll.exportFormat).toBe("png")
|
expect(poll.export.format).toBe("png")
|
||||||
expect(poll.exportOptions).toEqual({ width: 1000, pageId: "p2" })
|
expect(poll.export.options).toEqual({ width: 1000, pageId: "p2" })
|
||||||
|
|
||||||
await postJson("/api/state", {
|
await postJson("/api/state", {
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
exportData: "data:image/png;base64,AAAA",
|
exportData: "data:image/png;base64,AAAA",
|
||||||
exportId: poll.exportId,
|
exportId: poll.export.id,
|
||||||
})
|
})
|
||||||
expect(getState(id)?.exportOptions).toBeUndefined()
|
expect(getState(id)?.pendingExport).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("ignores a late result of an export that already timed out", async () => {
|
it("ignores a late result of an export that already timed out", async () => {
|
||||||
@@ -353,10 +350,10 @@ describe("export requests", () => {
|
|||||||
await postJson("/api/state", {
|
await postJson("/api/state", {
|
||||||
sessionId: id,
|
sessionId: id,
|
||||||
exportData: "data:image/png;base64,LATE",
|
exportData: "data:image/png;base64,LATE",
|
||||||
exportId: first.exportId,
|
exportId: first.export.id,
|
||||||
})
|
})
|
||||||
expect(getState(id)?.exportData).toBeUndefined()
|
expect(getState(id)?.exportData).toBeUndefined()
|
||||||
expect(getState(id)?.exportFormat).toBe("svg")
|
expect(getState(id)?.pendingExport?.format).toBe("svg")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -388,7 +385,7 @@ describe("a session state recreated after it was lost", () => {
|
|||||||
stateId,
|
stateId,
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(409)
|
expect(res.status).toBe(409)
|
||||||
expect(JSON.parse(res.body).stateChanged).toBe(true)
|
expect(JSON.parse(res.body).error).toBe("Session was recreated")
|
||||||
// The saved file was recovered first and is kept
|
// The saved file was recovered first and is kept
|
||||||
expect(getState("mcp-sid-restart")?.xml).toBe(SAVED)
|
expect(getState("mcp-sid-restart")?.xml).toBe(SAVED)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,15 +15,7 @@
|
|||||||
* (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface.
|
* (diagram-operations.ts) — i.e. the layers underneath the MCP tool surface.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import { installDomPolyfill } from "../src/dom.ts"
|
|
||||||
|
|
||||||
// Install the DOM polyfill exactly as index.ts does at runtime — the
|
|
||||||
// helpers under test rely on it.
|
|
||||||
beforeAll(() => {
|
|
||||||
installDomPolyfill()
|
|
||||||
})
|
|
||||||
|
|
||||||
import { applyDiagramOperations } from "../src/diagram-operations.ts"
|
import { applyDiagramOperations } from "../src/diagram-operations.ts"
|
||||||
import {
|
import {
|
||||||
addPageToDoc,
|
addPageToDoc,
|
||||||
|
|||||||
@@ -4,13 +4,7 @@
|
|||||||
* never sent.
|
* never sent.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import { installDomPolyfill } from "../src/dom.ts"
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
installDomPolyfill()
|
|
||||||
})
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
isMxCellXmlComplete,
|
isMxCellXmlComplete,
|
||||||
prepareNewDiagram,
|
prepareNewDiagram,
|
||||||
|
|||||||
@@ -15,14 +15,9 @@ import {
|
|||||||
} from "node:fs"
|
} from "node:fs"
|
||||||
import { homedir, tmpdir } from "node:os"
|
import { homedir, tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { afterEach, beforeAll, describe, expect, it } from "vitest"
|
import { afterEach, describe, expect, it } from "vitest"
|
||||||
import { installDomPolyfill } from "../src/dom.ts"
|
|
||||||
import { Autosaver, defaultDataDir } from "../src/persistence.ts"
|
import { Autosaver, defaultDataDir } from "../src/persistence.ts"
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
installDomPolyfill()
|
|
||||||
})
|
|
||||||
|
|
||||||
const DIAGRAM = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
const DIAGRAM = `<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
const BLANK = `<mxfile><diagram id="page-1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
const BLANK = `<mxfile><diagram id="page-1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Server-wiring test: boot the actual MCP stdio server (from source via tsx)
|
* Server-wiring test: boot the actual MCP stdio server (from source via tsx)
|
||||||
* and drive it the way a real MCP client does — initialize handshake,
|
* and drive it with the SDK's own client — initialize handshake, tools/list,
|
||||||
* tools/list — to catch registration/schema regressions that the unit tests
|
* tools/call — to catch registration/schema regressions that the unit tests
|
||||||
* (which import helpers directly) can't see.
|
* (which import helpers directly) can't see.
|
||||||
*
|
*
|
||||||
* This replaces the old standalone tests/smoke.mjs, which spawned the BUILT
|
* This replaces the old standalone tests/smoke.mjs, which spawned the BUILT
|
||||||
@@ -13,11 +13,13 @@
|
|||||||
* window via open(). The browser bridge is covered by the Playwright e2e suite.
|
* window via open(). The browser bridge is covered by the Playwright e2e suite.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
|
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||||
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||||
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
@@ -60,115 +62,69 @@ const MAX_DESCRIPTION = 2048
|
|||||||
// developer's real ~/.next-ai-drawio/instructions.md must not leak in
|
// developer's real ~/.next-ai-drawio/instructions.md must not leak in
|
||||||
const CUSTOM_RULE = "Always use blue fill #dae8fc."
|
const CUSTOM_RULE = "Always use blue fill #dae8fc."
|
||||||
|
|
||||||
let proc: ChildProcessWithoutNullStreams
|
let client: Client
|
||||||
let stdoutBuf = ""
|
|
||||||
const pending = new Map<
|
|
||||||
number,
|
|
||||||
{ resolve: (m: any) => void; reject: (e: Error) => void; timeout: any }
|
|
||||||
>()
|
|
||||||
let nextId = 1
|
|
||||||
let initResp: any
|
|
||||||
|
|
||||||
function send(method: string, params: unknown, isNotification = false) {
|
const listTools = async () => (await client.listTools()).tools
|
||||||
const msg: Record<string, unknown> = { jsonrpc: "2.0", method, params }
|
const findTool = async (name: string) =>
|
||||||
if (!isNotification) msg.id = nextId++
|
(await listTools()).find((t) => t.name === name)
|
||||||
proc.stdin.write(`${JSON.stringify(msg)}\n`)
|
const properties = async (name: string) =>
|
||||||
if (isNotification) return Promise.resolve(undefined)
|
(await findTool(name))?.inputSchema?.properties ?? {}
|
||||||
return new Promise<any>((resolve, reject) => {
|
|
||||||
const id = msg.id as number
|
/** A tool call's result; the SDK throws on a JSON-RPC error */
|
||||||
const timeout = setTimeout(() => {
|
const callTool = async (name: string, args: Record<string, unknown>) =>
|
||||||
pending.delete(id)
|
(await client.callTool({ name, arguments: args })) as CallToolResult
|
||||||
reject(new Error(`Timed out waiting for response to ${method}`))
|
const textOf = (result: CallToolResult) =>
|
||||||
}, 15000)
|
(result.content[0] as { text: string }).text
|
||||||
pending.set(id, { resolve, reject, timeout })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
writeFileSync(path.join(dataDir, "instructions.md"), CUSTOM_RULE)
|
writeFileSync(path.join(dataDir, "instructions.md"), CUSTOM_RULE)
|
||||||
writeFileSync(path.join(dataDir, `${SAVED_ID}.drawio`), SAVED_DIAGRAM)
|
writeFileSync(path.join(dataDir, `${SAVED_ID}.drawio`), SAVED_DIAGRAM)
|
||||||
proc = spawn(tsxBin, [entry], {
|
client = new Client({ name: "wiring-test", version: "0.0.0" })
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
await client.connect(
|
||||||
env: { ...process.env, DRAWIO_DATA_DIR: dataDir },
|
new StdioClientTransport({
|
||||||
}) as ChildProcessWithoutNullStreams
|
command: tsxBin,
|
||||||
|
args: [entry],
|
||||||
proc.stdout.on("data", (chunk: Buffer) => {
|
env: { ...process.env, DRAWIO_DATA_DIR: dataDir } as Record<
|
||||||
stdoutBuf += chunk.toString()
|
string,
|
||||||
const lines = stdoutBuf.split("\n")
|
string
|
||||||
stdoutBuf = lines.pop() || ""
|
>,
|
||||||
for (const line of lines) {
|
// The server's log lines
|
||||||
const trimmed = line.trim()
|
stderr: "ignore",
|
||||||
if (!trimmed) continue
|
}),
|
||||||
let msg: any
|
)
|
||||||
try {
|
expect(client.getServerVersion()?.name).toBeTruthy()
|
||||||
msg = JSON.parse(trimmed)
|
|
||||||
} catch {
|
|
||||||
// Non-JSON-RPC log line — ignore.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const p = msg.id !== undefined ? pending.get(msg.id) : undefined
|
|
||||||
if (p) {
|
|
||||||
clearTimeout(p.timeout)
|
|
||||||
pending.delete(msg.id)
|
|
||||||
p.resolve(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
initResp = await send("initialize", {
|
|
||||||
protocolVersion: "2024-11-05",
|
|
||||||
capabilities: {},
|
|
||||||
clientInfo: { name: "wiring-test", version: "0.0.0" },
|
|
||||||
})
|
|
||||||
expect(initResp.error, JSON.stringify(initResp.error)).toBeUndefined()
|
|
||||||
expect(initResp.result?.serverInfo?.name).toBeTruthy()
|
|
||||||
await send("notifications/initialized", {}, true)
|
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(async () => {
|
||||||
proc?.kill("SIGTERM")
|
await client?.close()
|
||||||
rmSync(dataDir, { recursive: true, force: true })
|
rmSync(dataDir, { recursive: true, force: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("MCP server wiring", () => {
|
describe("MCP server wiring", () => {
|
||||||
it("registers all tools", async () => {
|
it("registers all tools", async () => {
|
||||||
const resp = await send("tools/list", {})
|
const names = (await listTools()).map((t) => t.name)
|
||||||
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
|
|
||||||
const names: string[] = (resp.result?.tools ?? []).map(
|
|
||||||
(t: { name: string }) => t.name,
|
|
||||||
)
|
|
||||||
for (const expected of EXPECTED_TOOLS) {
|
for (const expected of EXPECTED_TOOLS) {
|
||||||
expect(names, `missing tool: ${expected}`).toContain(expected)
|
expect(names, `missing tool: ${expected}`).toContain(expected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it("advertises page-selector params on edit_diagram", async () => {
|
it("advertises page-selector params on edit_diagram", async () => {
|
||||||
const resp = await send("tools/list", {})
|
const props = await properties("edit_diagram")
|
||||||
const edit = resp.result.tools.find(
|
|
||||||
(t: { name: string }) => t.name === "edit_diagram",
|
|
||||||
)
|
|
||||||
const props = edit?.inputSchema?.properties ?? {}
|
|
||||||
expect(props.page_id).toBeTruthy()
|
expect(props.page_id).toBeTruthy()
|
||||||
expect(props.page_name).toBeTruthy()
|
expect(props.page_name).toBeTruthy()
|
||||||
expect(props.page_index).toBeTruthy()
|
expect(props.page_index).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("advertises session_id on start_session", async () => {
|
it("advertises session_id on start_session", async () => {
|
||||||
const resp = await send("tools/list", {})
|
const start = await findTool("start_session")
|
||||||
const start = resp.result.tools.find(
|
|
||||||
(t: { name: string }) => t.name === "start_session",
|
|
||||||
)
|
|
||||||
expect(start?.inputSchema?.properties?.session_id).toBeTruthy()
|
expect(start?.inputSchema?.properties?.session_id).toBeTruthy()
|
||||||
expect(start?.inputSchema?.required ?? []).not.toContain("session_id")
|
expect(start?.inputSchema?.required ?? []).not.toContain("session_id")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("lists the saved diagrams with their pages, without a session", async () => {
|
it("lists the saved diagrams with their pages, without a session", async () => {
|
||||||
const resp = await send("tools/call", {
|
const result = await callTool("list_saved_diagrams", {})
|
||||||
name: "list_saved_diagrams",
|
expect(result.isError).toBeFalsy()
|
||||||
arguments: {},
|
const text = textOf(result)
|
||||||
})
|
|
||||||
expect(resp.result.isError).toBeFalsy()
|
|
||||||
const text: string = resp.result.content[0].text
|
|
||||||
const line = text.split("\n").find((l) => l.startsWith(SAVED_ID))
|
const line = text.split("\n").find((l) => l.startsWith(SAVED_ID))
|
||||||
expect(line, text).toBeTruthy()
|
expect(line, text).toBeTruthy()
|
||||||
expect(line).toContain("pages: Flow (3 cells)")
|
expect(line).toContain("pages: Flow (3 cells)")
|
||||||
@@ -177,81 +133,59 @@ describe("MCP server wiring", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("advertises screenshot on create_new_diagram and edit_diagram", async () => {
|
it("advertises screenshot on create_new_diagram and edit_diagram", async () => {
|
||||||
const resp = await send("tools/list", {})
|
|
||||||
for (const name of ["create_new_diagram", "edit_diagram"]) {
|
for (const name of ["create_new_diagram", "edit_diagram"]) {
|
||||||
const tool = resp.result.tools.find(
|
const props = await properties(name)
|
||||||
(t: { name: string }) => t.name === name,
|
const screenshot = props.screenshot as { type?: string } | undefined
|
||||||
)
|
expect(screenshot?.type, `${name} screenshot`).toBe("boolean")
|
||||||
const props = tool?.inputSchema?.properties ?? {}
|
|
||||||
expect(props.screenshot?.type, `${name} screenshot`).toBe("boolean")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it("advertises name/id/xml on add_page", async () => {
|
it("advertises name/id/xml on add_page", async () => {
|
||||||
const resp = await send("tools/list", {})
|
const props = await properties("add_page")
|
||||||
const addPage = resp.result.tools.find(
|
|
||||||
(t: { name: string }) => t.name === "add_page",
|
|
||||||
)
|
|
||||||
const props = addPage?.inputSchema?.properties ?? {}
|
|
||||||
expect(props.name).toBeTruthy()
|
expect(props.name).toBeTruthy()
|
||||||
expect(props.id).toBeTruthy()
|
expect(props.id).toBeTruthy()
|
||||||
expect(props.xml).toBeTruthy()
|
expect(props.xml).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it("advertises steps_back on restore_version", async () => {
|
it("advertises steps_back on restore_version", async () => {
|
||||||
const resp = await send("tools/list", {})
|
const props = await properties("restore_version")
|
||||||
const restore = resp.result.tools.find(
|
expect(props.steps_back).toBeTruthy()
|
||||||
(t: { name: string }) => t.name === "restore_version",
|
|
||||||
)
|
|
||||||
expect(restore?.inputSchema?.properties?.steps_back).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("refuses restore_version without a session", async () => {
|
it("refuses restore_version without a session", async () => {
|
||||||
const resp = await send("tools/call", {
|
const result = await callTool("restore_version", {})
|
||||||
name: "restore_version",
|
expect(result.isError).toBe(true)
|
||||||
arguments: {},
|
expect(textOf(result)).toContain("start_session")
|
||||||
})
|
|
||||||
expect(resp.result.isError).toBe(true)
|
|
||||||
expect(resp.result.content[0].text).toContain("start_session")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("keeps every description and the instructions within the host limit", async () => {
|
it("keeps every description and the instructions within the host limit", async () => {
|
||||||
const resp = await send("tools/list", {})
|
for (const tool of await listTools()) {
|
||||||
for (const tool of resp.result.tools) {
|
|
||||||
expect(
|
expect(
|
||||||
tool.description.length,
|
tool.description?.length,
|
||||||
`${tool.name} description length`,
|
`${tool.name} description length`,
|
||||||
).toBeLessThanOrEqual(MAX_DESCRIPTION)
|
).toBeLessThanOrEqual(MAX_DESCRIPTION)
|
||||||
}
|
}
|
||||||
const instructions: string = initResp.result.instructions
|
const instructions = client.getInstructions() ?? ""
|
||||||
expect(instructions).toContain("start_session")
|
expect(instructions).toContain("start_session")
|
||||||
expect(instructions.length).toBeLessThanOrEqual(MAX_DESCRIPTION)
|
expect(instructions.length).toBeLessThanOrEqual(MAX_DESCRIPTION)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("serves a shape library without a session", async () => {
|
it("serves a shape library without a session", async () => {
|
||||||
const resp = await send("tools/call", {
|
const result = await callTool("get_shape_library", { library: "AWS4" })
|
||||||
name: "get_shape_library",
|
expect(result.isError).toBeFalsy()
|
||||||
arguments: { library: "AWS4" },
|
expect(textOf(result)).toContain("mxgraph.aws4")
|
||||||
})
|
|
||||||
expect(resp.result.isError).toBeFalsy()
|
|
||||||
expect(resp.result.content[0].text).toContain("mxgraph.aws4")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("reports an unknown shape library with the available names", async () => {
|
it("reports an unknown shape library with the available names", async () => {
|
||||||
const resp = await send("tools/call", {
|
const result = await callTool("get_shape_library", {
|
||||||
name: "get_shape_library",
|
library: "../secrets",
|
||||||
arguments: { library: "../secrets" },
|
|
||||||
})
|
})
|
||||||
expect(resp.result.isError).toBe(true)
|
expect(result.isError).toBe(true)
|
||||||
expect(resp.result.content[0].text).toContain("kubernetes")
|
expect(textOf(result)).toContain("kubernetes")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("serves the drawing guide without a session", async () => {
|
it("serves the drawing guide without a session", async () => {
|
||||||
const resp = await send("tools/call", {
|
const text = textOf(await callTool("get_drawing_guide", {}))
|
||||||
name: "get_drawing_guide",
|
|
||||||
arguments: {},
|
|
||||||
})
|
|
||||||
const text: string = resp.result.content[0].text
|
|
||||||
expect(text).toContain("Edge routing rules")
|
expect(text).toContain("Edge routing rules")
|
||||||
expect(text).toContain("do not describe the diagram")
|
expect(text).toContain("do not describe the diagram")
|
||||||
expect(text).toContain("clear the canvas")
|
expect(text).toContain("clear the canvas")
|
||||||
@@ -259,31 +193,21 @@ describe("MCP server wiring", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("appends the user's instructions.md to the drawing guide", async () => {
|
it("appends the user's instructions.md to the drawing guide", async () => {
|
||||||
const resp = await send("tools/call", {
|
const text = textOf(await callTool("get_drawing_guide", {}))
|
||||||
name: "get_drawing_guide",
|
|
||||||
arguments: {},
|
|
||||||
})
|
|
||||||
const text: string = resp.result.content[0].text
|
|
||||||
expect(
|
expect(
|
||||||
text.endsWith(`\n\n## Custom Instructions\n${CUSTOM_RULE}`),
|
text.endsWith(`\n\n## Custom Instructions\n${CUSTOM_RULE}`),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("advertises topic on get_drawing_guide", async () => {
|
it("advertises topic on get_drawing_guide", async () => {
|
||||||
const resp = await send("tools/list", {})
|
const props = await properties("get_drawing_guide")
|
||||||
const guide = resp.result.tools.find(
|
expect(props.topic).toBeTruthy()
|
||||||
(t: { name: string }) => t.name === "get_drawing_guide",
|
|
||||||
)
|
|
||||||
expect(guide?.inputSchema?.properties?.topic).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("serves the table XML reference with topic=tables", async () => {
|
it("serves the table XML reference with topic=tables", async () => {
|
||||||
const resp = await send("tools/call", {
|
const result = await callTool("get_drawing_guide", { topic: "tables" })
|
||||||
name: "get_drawing_guide",
|
expect(result.isError).toBeFalsy()
|
||||||
arguments: { topic: "tables" },
|
const text = textOf(result)
|
||||||
})
|
|
||||||
expect(resp.result.isError).toBeFalsy()
|
|
||||||
const text: string = resp.result.content[0].text
|
|
||||||
expect(text).toContain("shape=table")
|
expect(text).toContain("shape=table")
|
||||||
expect(text).not.toContain("Edge routing rules")
|
expect(text).not.toContain("Edge routing rules")
|
||||||
})
|
})
|
||||||
@@ -291,10 +215,7 @@ describe("MCP server wiring", () => {
|
|||||||
|
|
||||||
describe("load_diagram dual-source arguments", () => {
|
describe("load_diagram dual-source arguments", () => {
|
||||||
it("advertises both optional 'path' and 'xml' sources", async () => {
|
it("advertises both optional 'path' and 'xml' sources", async () => {
|
||||||
const resp = await send("tools/list", {})
|
const load = await findTool("load_diagram")
|
||||||
const load = resp.result.tools.find(
|
|
||||||
(t: { name: string }) => t.name === "load_diagram",
|
|
||||||
)
|
|
||||||
const props = load?.inputSchema?.properties ?? {}
|
const props = load?.inputSchema?.properties ?? {}
|
||||||
expect(props.path).toBeTruthy()
|
expect(props.path).toBeTruthy()
|
||||||
expect(props.xml).toBeTruthy()
|
expect(props.xml).toBeTruthy()
|
||||||
@@ -307,32 +228,25 @@ describe("load_diagram dual-source arguments", () => {
|
|||||||
// Argument validation fires before the session check: no session
|
// Argument validation fires before the session check: no session
|
||||||
// exists in this harness, so a both-args call must report the
|
// exists in this harness, so a both-args call must report the
|
||||||
// mutual-exclusion error, not "No active session".
|
// mutual-exclusion error, not "No active session".
|
||||||
const resp = await send("tools/call", {
|
const result = await callTool("load_diagram", {
|
||||||
name: "load_diagram",
|
path: "/tmp/x.drawio",
|
||||||
arguments: { path: "/tmp/x.drawio", xml: "<mxfile/>" },
|
xml: "<mxfile/>",
|
||||||
})
|
})
|
||||||
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
|
expect(result.isError).toBe(true)
|
||||||
expect(resp.result?.isError).toBe(true)
|
expect(textOf(result)).toContain("not both")
|
||||||
expect(resp.result?.content?.[0]?.text).toContain("not both")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it("rejects passing neither 'path' nor 'xml'", async () => {
|
it("rejects passing neither 'path' nor 'xml'", async () => {
|
||||||
const resp = await send("tools/call", {
|
const result = await callTool("load_diagram", {})
|
||||||
name: "load_diagram",
|
expect(result.isError).toBe(true)
|
||||||
arguments: {},
|
expect(textOf(result)).toContain("either 'path'")
|
||||||
})
|
|
||||||
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 () => {
|
it("accepts 'xml' alone as a source (fails only on the missing session)", async () => {
|
||||||
const resp = await send("tools/call", {
|
const result = await callTool("load_diagram", {
|
||||||
name: "load_diagram",
|
xml: '<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>',
|
||||||
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(result.isError).toBe(true)
|
||||||
expect(resp.result?.content?.[0]?.text).toContain("No active session")
|
expect(textOf(result)).toContain("No active session")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// The DOMParser and XMLSerializer globals the XML helpers need, installed
|
||||||
|
// before every test file as index.ts does at startup
|
||||||
|
import { installDomPolyfill } from "../src/dom.ts"
|
||||||
|
|
||||||
|
installDomPolyfill()
|
||||||
@@ -3,13 +3,7 @@
|
|||||||
* a page, like in the web app, and the server adds the wrapper and root cells.
|
* a page, like in the web app, and the server adds the wrapper and root cells.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import { installDomPolyfill } from "../src/dom.ts"
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
installDomPolyfill()
|
|
||||||
})
|
|
||||||
|
|
||||||
import { prepareNewDiagram, reservedIdError } from "../src/new-diagram.ts"
|
import { prepareNewDiagram, reservedIdError } from "../src/new-diagram.ts"
|
||||||
import { hasCells, wrapCellsInModel } from "../src/pages.ts"
|
import { hasCells, wrapCellsInModel } from "../src/pages.ts"
|
||||||
import { validateAndFixXml } from "../src/xml-validation.ts"
|
import { validateAndFixXml } from "../src/xml-validation.ts"
|
||||||
@@ -100,6 +94,22 @@ describe("hasCells", () => {
|
|||||||
hasCells(`<mxCell id = "0"/><mxCell id = "1" parent="0"/>`),
|
hasCells(`<mxCell id = "0"/><mxCell id = "1" parent="0"/>`),
|
||||||
).toBe(false)
|
).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("does not mistake ids that start with 0 or 1 for root cells", () => {
|
||||||
|
expect(
|
||||||
|
hasCells(
|
||||||
|
'<mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="10"/>',
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("counts a cell wrapped in a UserObject", () => {
|
||||||
|
expect(
|
||||||
|
hasCells(
|
||||||
|
'<mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="u" link="x"><mxCell vertex="1" parent="1"/></UserObject>',
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("reservedIdError", () => {
|
describe("reservedIdError", () => {
|
||||||
|
|||||||
@@ -4,13 +4,7 @@
|
|||||||
* whether draw.io renders it as intended is checked by hand.
|
* whether draw.io renders it as intended is checked by hand.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import { installDomPolyfill } from "../src/dom.ts"
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
installDomPolyfill()
|
|
||||||
})
|
|
||||||
|
|
||||||
import { prepareNewDiagram } from "../src/new-diagram.ts"
|
import { prepareNewDiagram } from "../src/new-diagram.ts"
|
||||||
import { hasCells } from "../src/pages.ts"
|
import { hasCells } from "../src/pages.ts"
|
||||||
import { XML_REFERENCE } from "../src/xml-reference.ts"
|
import { XML_REFERENCE } from "../src/xml-reference.ts"
|
||||||
|
|||||||
@@ -7,15 +7,9 @@
|
|||||||
* leave valid parts of the document untouched.
|
* leave valid parts of the document untouched.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeAll, describe, expect, it } from "vitest"
|
import { describe, expect, it } from "vitest"
|
||||||
import { installDomPolyfill } from "../src/dom.ts"
|
|
||||||
import { getXmlSyntaxError } from "../src/xml-syntax.ts"
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
installDomPolyfill()
|
|
||||||
})
|
|
||||||
|
|
||||||
import { addPageToDoc, parseMxfile, serializeMxfile } from "../src/pages.ts"
|
import { addPageToDoc, parseMxfile, serializeMxfile } from "../src/pages.ts"
|
||||||
|
import { getXmlSyntaxError } from "../src/xml-syntax.ts"
|
||||||
import { validateAndFixXml } from "../src/xml-validation.ts"
|
import { validateAndFixXml } from "../src/xml-validation.ts"
|
||||||
|
|
||||||
/** Bare model with the root cells plus the given cells. */
|
/** Bare model with the root cells plus the given cells. */
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"rewriteRelativeImportExtensions": true,
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
include: ["tests/**/*.test.ts"],
|
include: ["tests/**/*.test.ts"],
|
||||||
environment: "node",
|
environment: "node",
|
||||||
|
setupFiles: ["tests/setup.ts"],
|
||||||
// The package source uses Node16 module resolution with explicit .js
|
// The package source uses Node16 module resolution with explicit .js
|
||||||
// extensions in imports. Vitest+esbuild handles the .ts→.js mapping
|
// extensions in imports. Vitest+esbuild handles the .ts→.js mapping
|
||||||
// transparently, so no extra alias config is needed.
|
// transparently, so no extra alias config is needed.
|
||||||
|
|||||||
@@ -299,8 +299,9 @@ describe("review round: cells the model may write", () => {
|
|||||||
expect(outcome.xml).toContain(
|
expect(outcome.xml).toContain(
|
||||||
'<mxCell id="3" value="B" vertex="1" parent="L1"',
|
'<mxCell id="3" value="B" vertex="1" parent="L1"',
|
||||||
)
|
)
|
||||||
expect(foldCells(file, "L1")).toContain(
|
// Folding keeps a parent other than the default layer "1"
|
||||||
'<mxCell id="2" value="Start" style="rounded=1;" x="40" y="40" w="120" h="60"/>',
|
expect(foldCells(file)).toContain(
|
||||||
|
'<mxCell id="2" value="Start" style="rounded=1;" parent="L1" x="40" y="40" w="120" h="60"/>',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+1
-176
@@ -14,11 +14,7 @@ import {
|
|||||||
wrapCellsInModel,
|
wrapCellsInModel,
|
||||||
} from "@/packages/mcp-server/src/pages.ts"
|
} from "@/packages/mcp-server/src/pages.ts"
|
||||||
import { getXmlSyntaxError } from "@/packages/mcp-server/src/xml-syntax.ts"
|
import { getXmlSyntaxError } from "@/packages/mcp-server/src/xml-syntax.ts"
|
||||||
import {
|
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||||
autoFixXml,
|
|
||||||
validateAndFixXml,
|
|
||||||
validateMxCellStructure,
|
|
||||||
} from "@/packages/mcp-server/src/xml-validation.ts"
|
|
||||||
|
|
||||||
const box = (id: string, parent = "1") =>
|
const box = (id: string, parent = "1") =>
|
||||||
`<mxCell id="${id}" value="${id}" vertex="1" parent="${parent}"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
|
`<mxCell id="${id}" value="${id}" vertex="1" parent="${parent}"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
|
||||||
@@ -93,177 +89,6 @@ describe("MCP diagram modules with a browser DOM", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Repair cases fixed in the web app's own copy before it moved here
|
|
||||||
const page = (id: string, cells: string) =>
|
|
||||||
`<diagram name="${id}" id="${id}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
|
|
||||||
|
|
||||||
describe("duplicate ids in multi-page documents", () => {
|
|
||||||
const shape = (id: string, value = "Box") =>
|
|
||||||
`<mxCell id="${id}" value="${value}" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
|
|
||||||
|
|
||||||
it("accepts the same ids on different pages", () => {
|
|
||||||
const xml = `<mxfile>${page("p1", shape("2"))}${page("p2", shape("2"))}</mxfile>`
|
|
||||||
expect(validateMxCellStructure(xml)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it("still reports duplicate ids within one page", () => {
|
|
||||||
const xml = `<mxfile>${page("p1", shape("2") + shape("2"))}${page("p2", "")}</mxfile>`
|
|
||||||
expect(validateMxCellStructure(xml)).toMatch(/duplicate cell ID/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not rename the root cells of other pages when fixing", () => {
|
|
||||||
const xml = `<mxfile>${page("p1", shape("2", "R&D"))}${page("p2", shape("3"))}</mxfile>`
|
|
||||||
const result = validateAndFixXml(xml)
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.fixed).not.toContain("_dup")
|
|
||||||
expect(result.fixed).toContain("R&D")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("renames a duplicate id in a bare model, as display_diagram has", () => {
|
|
||||||
// In an <mxfile> the duplicate is reported instead (above)
|
|
||||||
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${shape("d") + shape("d")}</root></mxGraphModel>`
|
|
||||||
const { fixed } = autoFixXml(xml)
|
|
||||||
expect(fixed).toContain('<mxCell id="d" ')
|
|
||||||
expect(fixed).toContain('<mxCell id="d_dup1" ')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("autoFixXml", () => {
|
|
||||||
it("does not insert a space at the start of style values", () => {
|
|
||||||
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="R&D" style="rounded=1;whiteSpace=wrap;" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel>`
|
|
||||||
const { fixed } = autoFixXml(xml)
|
|
||||||
expect(fixed).toContain('style="rounded=1;whiteSpace=wrap;"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("adds a missing space between attributes", () => {
|
|
||||||
const xml = `<mxCell id="2" vertex="1"parent="1"/>`
|
|
||||||
expect(autoFixXml(xml).fixed).toContain('vertex="1" parent="1"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps " inside rich text labels", () => {
|
|
||||||
const label = "<font color="#ff0000">Hello</font>"
|
|
||||||
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="${label}" style="html=1;" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><mxCell id="3" value="Q&A" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></root></mxGraphModel>`
|
|
||||||
const result = validateAndFixXml(xml)
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.fixed).toContain(`value="${label}"`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("fixes an attribute delimited by "", () => {
|
|
||||||
const xml = `<mxCell id="2" dashPattern="1 1;" vertex="1" parent="1"/>`
|
|
||||||
expect(autoFixXml(xml).fixed).toContain('dashPattern="1 1;"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps cells written on one line next to multi-line cells", () => {
|
|
||||||
const xml = `<mxGraphModel><root>
|
|
||||||
<mxCell id="0"/>
|
|
||||||
<mxCell id="1" parent="0"/>
|
|
||||||
<mxCell id="2" value="Q&A" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="e1" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>
|
|
||||||
<mxCell id="3" value="B" vertex="1" parent="1">
|
|
||||||
<mxGeometry x="200" y="0" width="80" height="40" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
</root></mxGraphModel>`
|
|
||||||
const result = validateAndFixXml(xml)
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
for (const id of ["2", "e1", "3"]) {
|
|
||||||
expect(result.fixed).toContain(`<mxCell id="${id}"`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it("keeps object and UserObject wrappers", () => {
|
|
||||||
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="2" label="Docs" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><object id="3" label="A&B" owner="me"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></object></root></mxGraphModel>`
|
|
||||||
const result = validateAndFixXml(xml)
|
|
||||||
expect(result.valid).toBe(true)
|
|
||||||
expect(result.fixed).toContain('<UserObject id="2"')
|
|
||||||
expect(result.fixed).toContain('<object id="3"')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("hasCells (was isMinimalDiagram in the web app)", () => {
|
|
||||||
it("returns true for empty diagram", () => {
|
|
||||||
const xml = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
|
|
||||||
expect(hasCells(xml)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns false for diagram with content", () => {
|
|
||||||
const xml =
|
|
||||||
'<mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="Hello"/>'
|
|
||||||
expect(hasCells(xml)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("handles whitespace correctly", () => {
|
|
||||||
const xml = ' <mxCell id="0"/> <mxCell id="1" parent="0"/> '
|
|
||||||
expect(hasCells(xml)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("returns false for a shape drawn in draw.io with a random id", () => {
|
|
||||||
const xml =
|
|
||||||
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="xY3kQ9-1" value="" style="rounded=0;" vertex="1" parent="1"><mxGeometry x="10" y="10" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>'
|
|
||||||
expect(hasCells(xml)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("does not mistake ids that start with 0 or 1 for root cells", () => {
|
|
||||||
const xml =
|
|
||||||
'<mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="10"/>'
|
|
||||||
expect(hasCells(xml)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("counts a cell wrapped in a UserObject", () => {
|
|
||||||
const xml =
|
|
||||||
'<mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="u" link="x"><mxCell vertex="1" parent="1"/></UserObject>'
|
|
||||||
expect(hasCells(xml)).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("applyDiagramOperations with wrapped cells", () => {
|
|
||||||
const xml = `<mxfile><diagram id="p1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><UserObject id="5" label="Docs" link="https://example.com"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject><mxCell id="6" value="B" vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell><mxCell id="e1" edge="1" parent="1" source="5" target="6"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`
|
|
||||||
|
|
||||||
it("deletes a wrapped cell and its edges", () => {
|
|
||||||
const { result, errors } = applyDiagramOperations(xml, [
|
|
||||||
{ operation: "delete", cell_id: "5" },
|
|
||||||
{ operation: "delete", cell_id: "e1" },
|
|
||||||
])
|
|
||||||
expect(errors).toEqual([])
|
|
||||||
expect(result).not.toContain("UserObject")
|
|
||||||
expect(result).not.toContain('id="e1"')
|
|
||||||
expect(result).toContain('id="6"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("rejects adding a cell with the id of a wrapped cell", () => {
|
|
||||||
const { errors } = applyDiagramOperations(xml, [
|
|
||||||
{
|
|
||||||
operation: "add",
|
|
||||||
cell_id: "5",
|
|
||||||
new_xml: '<mxCell id="5" vertex="1" parent="1"/>',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(errors[0]?.message).toContain("already exists")
|
|
||||||
})
|
|
||||||
|
|
||||||
it("updates a wrapped cell", () => {
|
|
||||||
const { result, errors } = applyDiagramOperations(xml, [
|
|
||||||
{
|
|
||||||
operation: "update",
|
|
||||||
cell_id: "5",
|
|
||||||
new_xml:
|
|
||||||
'<UserObject id="5" label="New" link="https://example.org"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(errors).toEqual([])
|
|
||||||
expect(result).toContain('label="New"')
|
|
||||||
expect(result).not.toContain('label="Docs"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("reports deleting a cell that does not exist", () => {
|
|
||||||
const { errors } = applyDiagramOperations(xml, [
|
|
||||||
{ operation: "delete", cell_id: "missing" },
|
|
||||||
])
|
|
||||||
expect(errors[0]?.message).toContain("not found")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("edit_diagram on one page of a multi-page file", () => {
|
describe("edit_diagram on one page of a multi-page file", () => {
|
||||||
// Page A has an edge "g"; on page B, "g" is a shape on layer "L"
|
// Page A has an edge "g"; on page B, "g" is a shape on layer "L"
|
||||||
const twoPages = `<mxfile><diagram id="pa" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${box("s")}${box("t")}<mxCell id="g" edge="1" parent="1" source="s" target="t"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="pb" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="L" parent="0"/>${box("g", "L")}</root></mxGraphModel></diagram></mxfile>`
|
const twoPages = `<mxfile><diagram id="pa" name="A"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${box("s")}${box("t")}<mxCell id="g" edge="1" parent="1" source="s" target="t"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="pb" name="B"><mxGraphModel><root><mxCell id="0"/><mxCell id="L" parent="0"/>${box("g", "L")}</root></mxGraphModel></diagram></mxfile>`
|
||||||
|
|||||||
@@ -445,7 +445,7 @@ describe("MCP preview History and a recreated session", () => {
|
|||||||
}
|
}
|
||||||
const historyList = {
|
const historyList = {
|
||||||
status: 200,
|
status: 200,
|
||||||
body: { entries: [{ id: 5, index: 0, svg: "" }], count: 1 },
|
body: { entries: [{ id: 5, index: 0, svg: "" }] },
|
||||||
}
|
}
|
||||||
const modalOpen = () =>
|
const modalOpen = () =>
|
||||||
document.getElementById("history-modal")?.classList.contains("open")
|
document.getElementById("history-modal")?.classList.contains("open")
|
||||||
|
|||||||
@@ -9,10 +9,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
addDefaultStyles,
|
addDefaultStyles,
|
||||||
applyStyleClasses,
|
applyStyleClasses,
|
||||||
expandStyles,
|
|
||||||
readStyleClasses,
|
readStyleClasses,
|
||||||
} from "@/packages/mcp-server/src/style-classes.ts"
|
} from "@/packages/mcp-server/src/style-classes.ts"
|
||||||
|
|
||||||
|
/** The named styles of a model's XML expanded, with the default styles */
|
||||||
|
const expandStyles = (xml: string) => {
|
||||||
|
const { classes, xml: cells } = readStyleClasses(xml)
|
||||||
|
return addDefaultStyles(applyStyleClasses(cells, classes))
|
||||||
|
}
|
||||||
|
|
||||||
const geometry =
|
const geometry =
|
||||||
'<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>'
|
'<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>'
|
||||||
const cell = (id: string, style: string, extra = "") =>
|
const cell = (id: string, style: string, extra = "") =>
|
||||||
|
|||||||
Reference in New Issue
Block a user