mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-05 01:07:56 +08:00
refactor(web): validate and repair diagram XML with the MCP server's engine
- Delete the web app's own copy of the XML checks and repairs from lib/utils.ts (1,074 lines). loadDiagram now uses the MCP server's validateAndFixXml without the strict checks, because the XML may hold the user's own diagram - display_diagram and append_diagram prepare the model's XML with the new shared prepareNewDiagram, also used by the MCP create_new_diagram: wrap, validate strictly and auto-fix while it is still a bare model (where duplicate ids are renamed), then turn it into an mxfile - The streaming preview of display_diagram no longer redraws the model's raw cells after the tool handler loaded the checked diagram, and drops a queued preview once the input is complete. That redraw lost auto-fixes and UserObject/object wrappers, so a linked cell lost its label; it also showed a second error toast - The web repair regression tests now run against the MCP functions - New e2e test checks the canvas content after display_diagram - Fix the e2e upload tests, whose file input locator also matched the template import input
This commit is contained in:
@@ -41,7 +41,6 @@ import {
|
||||
convertToLegalXml,
|
||||
extractCompleteMxCells,
|
||||
replaceNodes,
|
||||
validateAndFixXml,
|
||||
} from "@/lib/utils"
|
||||
|
||||
// Helper to extract complete operations from streaming input
|
||||
@@ -345,73 +344,32 @@ export function ChatMessageDisplay({
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming preview of display_diagram: draw the complete cells written
|
||||
// so far. The tool handler validates and loads the final diagram.
|
||||
const handleDisplayChart = useCallback(
|
||||
(xml: string, showToast = false) => {
|
||||
let currentXml = xml || ""
|
||||
(xml: string) => {
|
||||
const completeCells = extractCompleteMxCells(xml || "")
|
||||
if (!completeCells) return
|
||||
const convertedXml = convertToLegalXml(completeCells)
|
||||
if (convertedXml === previousXML.current) return
|
||||
|
||||
// During streaming (showToast=false), extract only complete mxCell elements
|
||||
// This allows progressive rendering even with partial/incomplete trailing XML
|
||||
if (!showToast) {
|
||||
const completeCells = extractCompleteMxCells(currentXml)
|
||||
if (!completeCells) {
|
||||
return
|
||||
}
|
||||
currentXml = completeCells
|
||||
}
|
||||
// Skip this update while the cells written so far don't parse
|
||||
const testDoc = new DOMParser().parseFromString(
|
||||
`<root>${convertedXml}</root>`,
|
||||
"text/xml",
|
||||
)
|
||||
if (testDoc.querySelector("parsererror")) return
|
||||
|
||||
const convertedXml = convertToLegalXml(currentXml)
|
||||
if (convertedXml !== previousXML.current) {
|
||||
// Parse and validate XML BEFORE calling replaceNodes
|
||||
const parser = new DOMParser()
|
||||
// Wrap in root element for parsing multiple mxCell elements
|
||||
const testDoc = parser.parseFromString(
|
||||
`<root>${convertedXml}</root>`,
|
||||
"text/xml",
|
||||
)
|
||||
const parseError = testDoc.querySelector("parsererror")
|
||||
|
||||
if (parseError) {
|
||||
// Only show toast if this is the final XML (not during streaming)
|
||||
if (showToast) {
|
||||
toast.error(dict.errors.malformedXml)
|
||||
}
|
||||
return // Skip this update
|
||||
}
|
||||
|
||||
try {
|
||||
// If chartXML is empty, create a default mxfile structure to use with replaceNodes
|
||||
// This ensures the XML is properly wrapped in mxfile/diagram/mxGraphModel format
|
||||
const baseXML =
|
||||
chartXML ||
|
||||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||
|
||||
// During streaming (showToast=false), skip heavy validation for lower latency
|
||||
// The quick DOM parse check above catches malformed XML
|
||||
// Full validation runs on final output (showToast=true)
|
||||
if (!showToast) {
|
||||
previousXML.current = convertedXml
|
||||
onDisplayChart(replacedXML, true)
|
||||
return
|
||||
}
|
||||
|
||||
// Final output: run full validation and auto-fix
|
||||
const validation = validateAndFixXml(replacedXML)
|
||||
if (validation.valid) {
|
||||
previousXML.current = convertedXml
|
||||
// Use fixed XML if available, otherwise use original
|
||||
const xmlToLoad = validation.fixed || replacedXML
|
||||
onDisplayChart(xmlToLoad, true)
|
||||
} else {
|
||||
toast.error(dict.errors.validationFailed)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing XML:", error)
|
||||
// Only show toast if this is the final XML (not during streaming)
|
||||
if (showToast) {
|
||||
toast.error(dict.errors.failedToProcess)
|
||||
}
|
||||
}
|
||||
try {
|
||||
// An empty canvas gets a default mxfile to put the cells in
|
||||
const baseXML =
|
||||
chartXML ||
|
||||
`<mxfile><diagram name="Page-1" id="page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>`
|
||||
const replacedXML = replaceNodes(baseXML, convertedXml)
|
||||
previousXML.current = convertedXml
|
||||
onDisplayChart(replacedXML, true)
|
||||
} catch (error) {
|
||||
console.error("Error processing XML:", error)
|
||||
}
|
||||
},
|
||||
[chartXML, onDisplayChart],
|
||||
@@ -497,10 +455,7 @@ export function ChatMessageDisplay({
|
||||
return // Skip redundant processing
|
||||
}
|
||||
|
||||
if (
|
||||
state === "input-streaming" ||
|
||||
state === "input-available"
|
||||
) {
|
||||
if (state === "input-streaming") {
|
||||
// Debounce streaming updates - queue the XML and process after delay
|
||||
pendingXmlRef.current = xml
|
||||
|
||||
@@ -513,10 +468,7 @@ export function ChatMessageDisplay({
|
||||
debounceTimeoutRef.current = null
|
||||
pendingXmlRef.current = null
|
||||
if (pendingXml) {
|
||||
handleDisplayChart(
|
||||
pendingXml,
|
||||
false,
|
||||
)
|
||||
handleDisplayChart(pendingXml)
|
||||
lastProcessedXmlRef.current.set(
|
||||
toolCallId,
|
||||
pendingXml,
|
||||
@@ -527,17 +479,16 @@ export function ChatMessageDisplay({
|
||||
)
|
||||
}
|
||||
} else if (
|
||||
state === "output-available" &&
|
||||
!processedToolCalls.current.has(toolCallId)
|
||||
) {
|
||||
// Final output - process immediately (clear any pending debounce)
|
||||
// Input complete: the tool handler loads the
|
||||
// validated diagram, so drop a queued preview
|
||||
// that would draw the raw cells over it
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current)
|
||||
debounceTimeoutRef.current = null
|
||||
pendingXmlRef.current = null
|
||||
}
|
||||
// Show toast only if final XML is malformed
|
||||
handleDisplayChart(xml, true)
|
||||
processedToolCalls.current.add(toolCallId)
|
||||
// Clean up the ref entry - tool is complete, no longer needed
|
||||
lastProcessedXmlRef.current.delete(toolCallId)
|
||||
|
||||
@@ -6,11 +6,8 @@ import type { DrawIoEmbedRef, EventExport } from "react-drawio"
|
||||
import { toast } from "sonner"
|
||||
import type { ExportFormat } from "@/components/save-dialog"
|
||||
import { getApiEndpoint } from "@/lib/base-path"
|
||||
import {
|
||||
extractDiagramXML,
|
||||
isRealDiagram,
|
||||
validateAndFixXml,
|
||||
} from "../lib/utils"
|
||||
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||
import { extractDiagramXML, isRealDiagram } from "../lib/utils"
|
||||
|
||||
interface DiagramContextType {
|
||||
chartXML: string
|
||||
@@ -170,9 +167,11 @@ export function DiagramProvider({ children }: { children: React.ReactNode }) {
|
||||
): string | null => {
|
||||
let xmlToLoad = chart
|
||||
|
||||
// Validate XML structure before loading (unless skipped for internal use)
|
||||
// Validate XML structure before loading (unless skipped for internal
|
||||
// use). Not strict: the XML may hold the user's own diagram, and the
|
||||
// tool handlers check model XML strictly before it gets here.
|
||||
if (!skipValidation) {
|
||||
const validation = validateAndFixXml(chart)
|
||||
const validation = validateAndFixXml(chart, { strict: false })
|
||||
if (!validation.valid) {
|
||||
console.warn(
|
||||
"[loadDiagram] Validation error:",
|
||||
|
||||
@@ -6,10 +6,14 @@ import type {
|
||||
} from "@/components/chat/ValidationCard"
|
||||
import type { ValidationResult } from "@/lib/diagram-validator"
|
||||
import { formatValidationFeedback } from "@/lib/diagram-validator"
|
||||
import { isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
|
||||
import { isMxCellXmlComplete } from "@/lib/utils"
|
||||
import { prepareNewDiagram } from "@/packages/mcp-server/src/new-diagram.ts"
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === "development"
|
||||
|
||||
// display_diagram replaces the document with this one page
|
||||
const NEW_PAGE = { pageId: "page-1", pageName: "Page-1" }
|
||||
|
||||
interface ToolCall {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
@@ -173,11 +177,12 @@ NEXT STEP: Call append_diagram with the continuation XML.
|
||||
const finalXml = xml
|
||||
partialXmlRef.current = "" // Reset any partial from previous truncation
|
||||
|
||||
// Wrap raw XML with full mxfile structure for draw.io
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
|
||||
// loadDiagram validates and returns error if invalid
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
// Wrap, validate and auto-fix the model's XML like the MCP server's
|
||||
// create_new_diagram, then load it
|
||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||
const validationError = prepared.ok
|
||||
? onDisplayChart(prepared.xml, true)
|
||||
: prepared.error
|
||||
|
||||
if (validationError) {
|
||||
console.warn("[display_diagram] Validation error:", validationError)
|
||||
@@ -545,8 +550,10 @@ Start your continuation with the NEXT character after where it stopped.`,
|
||||
const finalXml = partialXmlRef.current
|
||||
partialXmlRef.current = "" // Reset
|
||||
|
||||
const fullXml = wrapWithMxFile(finalXml)
|
||||
const validationError = onDisplayChart(fullXml)
|
||||
const prepared = prepareNewDiagram(finalXml, NEW_PAGE)
|
||||
const validationError = prepared.ok
|
||||
? onDisplayChart(prepared.xml, true)
|
||||
: prepared.error
|
||||
|
||||
if (validationError) {
|
||||
addToolOutput({
|
||||
|
||||
@@ -178,9 +178,6 @@
|
||||
"networkError": "Network error. Please check your connection.",
|
||||
"retryLimit": "Auto-retry limit reached ({max}). Please try again manually.",
|
||||
"continuationRetryLimit": "Continuation retry limit reached ({max}). The diagram may be too complex.",
|
||||
"validationFailed": "Diagram validation failed. Please try regenerating.",
|
||||
"malformedXml": "AI generated invalid diagram XML. Please try regenerating.",
|
||||
"failedToProcess": "Failed to process diagram. Please try regenerating.",
|
||||
"sessionCorrupted": "Session data was corrupted. Starting fresh.",
|
||||
"failedToSave": "Failed to save messages to localStorage",
|
||||
"failedToRestore": "Failed to restore from localStorage",
|
||||
|
||||
@@ -178,9 +178,6 @@
|
||||
"networkError": "ネットワークエラー。接続を確認してください。",
|
||||
"retryLimit": "自動再試行制限に達しました({max})。手動で再試行してください。",
|
||||
"continuationRetryLimit": "継続再試行制限に達しました({max})。ダイアグラムが複雑すぎる可能性があります。",
|
||||
"validationFailed": "ダイアグラムの検証に失敗しました。再生成してみてください。",
|
||||
"malformedXml": "AI が無効なダイアグラム XML を生成しました。再生成してみてください。",
|
||||
"failedToProcess": "ダイアグラムの処理に失敗しました。再生成してみてください。",
|
||||
"sessionCorrupted": "セッションデータが破損しました。最初からやり直します。",
|
||||
"failedToSave": "localStorage へのメッセージの保存に失敗しました",
|
||||
"failedToRestore": "localStorage からの復元に失敗しました",
|
||||
|
||||
@@ -178,9 +178,6 @@
|
||||
"networkError": "網路錯誤。請檢查您的連線。",
|
||||
"retryLimit": "已達自動重試限制({max})。請手動重試。",
|
||||
"continuationRetryLimit": "已達繼續重試限制({max})。圖表可能過於複雜。",
|
||||
"validationFailed": "圖表驗證失敗。請嘗試重新產生。",
|
||||
"malformedXml": "AI 產生的圖表 XML 無效。請嘗試重新產生。",
|
||||
"failedToProcess": "無法處理圖表。請嘗試重新產生。",
|
||||
"sessionCorrupted": "工作階段資料已損壞。重新開始。",
|
||||
"failedToSave": "無法儲存訊息到 localStorage",
|
||||
"failedToRestore": "無法從 localStorage 還原",
|
||||
|
||||
@@ -178,9 +178,6 @@
|
||||
"networkError": "网络错误。请检查您的连接。",
|
||||
"retryLimit": "已达到自动重试限制({max})。请手动重试。",
|
||||
"continuationRetryLimit": "已达到继续重试限制({max})。图表可能过于复杂。",
|
||||
"validationFailed": "图表验证失败。请尝试重新生成。",
|
||||
"malformedXml": "AI 生成的图表 XML 无效。请尝试重新生成。",
|
||||
"failedToProcess": "无法处理图表。请尝试重新生成。",
|
||||
"sessionCorrupted": "会话数据已损坏。重新开始。",
|
||||
"failedToSave": "无法保存消息到 localStorage",
|
||||
"failedToRestore": "无法从 localStorage 恢复",
|
||||
|
||||
-1074
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,7 @@ import {
|
||||
} from "./http-server.ts"
|
||||
import { parseDrawioFileContent } from "./load-diagram.ts"
|
||||
import { log } from "./logger.ts"
|
||||
import { prepareNewDiagram } from "./new-diagram.ts"
|
||||
import {
|
||||
addPageToDoc,
|
||||
deletePageFromDoc,
|
||||
@@ -341,44 +342,21 @@ Rules: cells are siblings (never nested), ids are unique per page and start from
|
||||
}
|
||||
}
|
||||
|
||||
// Bare cells get the wrapper and root cells first: the strict
|
||||
// parser rejects several top-level elements. Then validate and
|
||||
// auto-fix (works for both mxfile and mxGraphModel inputs).
|
||||
let xml = wrapCellsInModel(inputXml)
|
||||
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
|
||||
if (fixed) {
|
||||
xml = fixed
|
||||
log.info(`XML auto-fixed: ${fixes.join(", ")}`)
|
||||
}
|
||||
if (!valid && error) {
|
||||
log.error(`XML validation failed: ${error}`)
|
||||
const prepared = prepareNewDiagram(inputXml)
|
||||
if (!prepared.ok) {
|
||||
log.error(prepared.error)
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Error: XML validation failed - ${error}`,
|
||||
},
|
||||
{ type: "text", text: `Error: ${prepared.error}` },
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Normalise to the canonical mxfile shape so every later tool can
|
||||
// assume "session.xml is always an mxfile". Bare <mxGraphModel>
|
||||
// inputs are wrapped into a single-page mxfile here.
|
||||
const normalized = normalizeToMxfile(xml)
|
||||
if (!normalized) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: XML must be the mxCell elements of one page, a <mxGraphModel>, or an <mxfile> with one or more <diagram> children.",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
if (prepared.fixes.length > 0) {
|
||||
log.info(`XML auto-fixed: ${prepared.fixes.join(", ")}`)
|
||||
}
|
||||
xml = normalized
|
||||
// Every later tool can assume session.xml is an mxfile
|
||||
const xml = prepared.xml
|
||||
|
||||
log.info(`Setting diagram content, ${xml.length} chars`)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* A whole new diagram written by the model, for the create_new_diagram tool
|
||||
* and the web app's display_diagram tool.
|
||||
*/
|
||||
import { normalizeToMxfile, wrapCellsInModel } from "./pages.ts"
|
||||
import { validateAndFixXml } from "./xml-validation.ts"
|
||||
|
||||
export type NewDiagram =
|
||||
| { ok: true; xml: string; fixes: string[] }
|
||||
| { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Bare cells get the wrapper and root cells first, 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, and finally turned into an <mxfile>.
|
||||
*/
|
||||
export function prepareNewDiagram(
|
||||
input: string,
|
||||
page: { pageId?: string; pageName?: string } = {},
|
||||
): NewDiagram {
|
||||
let xml = wrapCellsInModel(input)
|
||||
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
|
||||
if (fixed) xml = fixed
|
||||
if (!valid) {
|
||||
return { ok: false, error: `XML validation failed - ${error}` }
|
||||
}
|
||||
const normalized = normalizeToMxfile(xml, page)
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { getIframe, sendMessage, waitForCompleteCount } from "./lib/fixtures"
|
||||
|
||||
/**
|
||||
* Checks what draw.io actually shows after display_diagram, not only the
|
||||
* tool card. The tool input is streamed in chunks like a real model, and
|
||||
* the browser tool handler (not the server) completes the tool call.
|
||||
*/
|
||||
function streamedToolCall(xml: string) {
|
||||
const toolCallId = `call_${Math.random().toString(36).slice(2)}`
|
||||
const input = JSON.stringify({ xml })
|
||||
const chunks = input.match(/[\s\S]{1,40}/g) ?? []
|
||||
const events = [
|
||||
{ type: "start", messageId: `msg_${toolCallId}` },
|
||||
{ type: "tool-input-start", toolCallId, toolName: "display_diagram" },
|
||||
...chunks.map((inputTextDelta) => ({
|
||||
type: "tool-input-delta",
|
||||
toolCallId,
|
||||
inputTextDelta,
|
||||
})),
|
||||
{
|
||||
type: "tool-input-available",
|
||||
toolCallId,
|
||||
toolName: "display_diagram",
|
||||
input: { xml },
|
||||
},
|
||||
{ type: "finish" },
|
||||
]
|
||||
return `${events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")}data: [DONE]\n\n`
|
||||
}
|
||||
|
||||
const cell = (id: string, label: string, x: number) =>
|
||||
`<mxCell id="${id}" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="${x}" y="40" width="120" height="60" as="geometry"/></mxCell>`
|
||||
const page = (id: string, cells: string) =>
|
||||
`<diagram id="${id}" name="${id}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram>`
|
||||
|
||||
const TWO_PAGES = `<mxfile>${page("First", cell("a", "Old A", 40))}${page("Second", cell("b", "Old B", 40))}</mxfile>`
|
||||
// Bare cells with a duplicate id and an unescaped &, which get fixed, and
|
||||
// a linked cell whose label lives on its UserObject wrapper
|
||||
const NEW_CELLS =
|
||||
cell("2", "Alpha", 40) +
|
||||
cell("2", "Beta", 220) +
|
||||
cell("3", "R&D", 400) +
|
||||
`<UserObject id="4" label="Docs" link="https://example.com"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="580" y="40" width="120" height="60" as="geometry"/></mxCell></UserObject>`
|
||||
|
||||
test("display_diagram replaces the document with the fixed diagram", async ({
|
||||
page: p,
|
||||
}) => {
|
||||
const replies = [TWO_PAGES, NEW_CELLS]
|
||||
await p.route("**/api/chat", async (route) => {
|
||||
const xml = replies.shift()
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: xml
|
||||
? streamedToolCall(xml)
|
||||
: 'data: {"type":"start"}\n\ndata: {"type":"finish"}\n\ndata: [DONE]\n\n',
|
||||
})
|
||||
})
|
||||
await p.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(p).waitFor({ state: "visible", timeout: 30000 })
|
||||
const canvas = p.frameLocator("iframe")
|
||||
|
||||
await sendMessage(p, "Draw two pages")
|
||||
await waitForCompleteCount(p, 1)
|
||||
await expect(canvas.getByText("Old A")).toBeVisible({ timeout: 15000 })
|
||||
await expect(canvas.getByText("Second", { exact: true })).toBeVisible()
|
||||
|
||||
await sendMessage(p, "Start over with three boxes")
|
||||
await waitForCompleteCount(p, 2)
|
||||
// Give a late preview time to redraw the raw cells, as it used to
|
||||
await p.waitForTimeout(1000)
|
||||
for (const label of ["Alpha", "Beta", "R&D", "Docs"]) {
|
||||
await expect(canvas.getByText(label, { exact: true })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
}
|
||||
// The old pages are gone
|
||||
await expect(canvas.getByText("Old A")).toHaveCount(0)
|
||||
await expect(canvas.getByText("Second", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
@@ -24,7 +24,8 @@ test.describe("File Upload", () => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
const fileInput = page.locator('input[type="file"]')
|
||||
// The chat attachment input; the template panel has its own file input
|
||||
const fileInput = page.locator('input[type="file"][multiple]')
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: "test-image.png",
|
||||
@@ -44,7 +45,8 @@ test.describe("File Upload", () => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
const fileInput = page.locator('input[type="file"]')
|
||||
// The chat attachment input; the template panel has its own file input
|
||||
const fileInput = page.locator('input[type="file"][multiple]')
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: "test-image.png",
|
||||
@@ -91,7 +93,8 @@ test.describe("File Upload", () => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
const fileInput = page.locator('input[type="file"]')
|
||||
// The chat attachment input; the template panel has its own file input
|
||||
const fileInput = page.locator('input[type="file"][multiple]')
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: "architecture.png",
|
||||
@@ -115,7 +118,8 @@ test.describe("File Upload", () => {
|
||||
await page.goto("/", { waitUntil: "networkidle" })
|
||||
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
|
||||
|
||||
const fileInput = page.locator('input[type="file"]')
|
||||
// The chat attachment input; the template panel has its own file input
|
||||
const fileInput = page.locator('input[type="file"][multiple]')
|
||||
const largeBuffer = Buffer.alloc(3 * 1024 * 1024, "x")
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
wrapCellsInModel,
|
||||
} from "@/packages/mcp-server/src/pages.ts"
|
||||
import { getXmlSyntaxError } from "@/packages/mcp-server/src/xml-syntax.ts"
|
||||
import { validateAndFixXml } from "@/packages/mcp-server/src/xml-validation.ts"
|
||||
import {
|
||||
autoFixXml,
|
||||
validateAndFixXml,
|
||||
validateMxCellStructure,
|
||||
} from "@/packages/mcp-server/src/xml-validation.ts"
|
||||
|
||||
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>`
|
||||
@@ -88,3 +92,91 @@ describe("MCP diagram modules with a browser DOM", () => {
|
||||
expect(decompressPageContent("not compressed")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// 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"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
applyDiagramOperations,
|
||||
autoFixXml,
|
||||
cn,
|
||||
extractCompleteMxCells,
|
||||
isMxCellXmlComplete,
|
||||
validateAndFixXml,
|
||||
validateMxCellStructure,
|
||||
wrapWithMxFile,
|
||||
} from "@/lib/utils"
|
||||
|
||||
@@ -136,92 +133,6 @@ describe("extractCompleteMxCells", () => {
|
||||
})
|
||||
})
|
||||
|
||||
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)).toContain("duplicate ID")
|
||||
})
|
||||
|
||||
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 within a page", () => {
|
||||
const xml = `<mxfile>${page("p1", shape("d") + shape("d"))}</mxfile>`
|
||||
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("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>`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user