fix: improve Mermaid diagram error handling with helpful suggestions

- Add enhanced error messages with specific suggestions for common syntax errors
- Detect unclosed brackets, braces, incomplete arrows, and missing diagram types
- Add comprehensive test coverage for error handling scenarios
- Improve error display with proper whitespace formatting

Fixes #6712
This commit is contained in:
Roo Code 2025-08-05 10:16:53 +00:00
parent d90bab71ff
commit 0f55ac9062
2 changed files with 347 additions and 3 deletions

View file

@ -95,6 +95,53 @@ export default function MermaidBlock({ code }: MermaidBlockProps) {
const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
const { t } = useAppTranslation()
// Helper function to enhance error messages with suggestions
const enhanceErrorMessage = (originalError: string, code: string): string => {
let enhancedMessage = originalError
// Check for common syntax errors
if (originalError.includes("LINK_ID") && originalError.includes("Expecting")) {
// Count brackets to check for unclosed brackets
const openBrackets = (code.match(/\[/g) || []).length
const closeBrackets = (code.match(/\]/g) || []).length
const openBraces = (code.match(/\{/g) || []).length
const closeBraces = (code.match(/\}/g) || []).length
if (openBrackets > closeBrackets) {
enhancedMessage +=
"\n\nSuggestion: You have unclosed square brackets '['. Make sure all node labels are properly closed with ']'."
}
if (openBraces > closeBraces) {
enhancedMessage +=
"\n\nSuggestion: You have unclosed curly braces '{'. Make sure all decision nodes are properly closed with '}'."
}
// Check for incomplete node definitions
if (code.includes("@") && !code.includes("]") && !code.includes("}")) {
enhancedMessage +=
"\n\nSuggestion: Node labels containing special characters like '@' should be properly enclosed in brackets."
}
}
// Check for other common issues
if (originalError.includes("Parse error") && code.trim().endsWith("-->")) {
enhancedMessage +=
"\n\nSuggestion: Your diagram appears to end with an arrow '-->'. Make sure to complete the connection with a target node."
}
if (
!code.trim().startsWith("graph") &&
!code.trim().startsWith("flowchart") &&
!code.trim().startsWith("sequenceDiagram") &&
!code.trim().startsWith("classDiagram")
) {
enhancedMessage +=
"\n\nSuggestion: Make sure your diagram starts with a valid diagram type (e.g., 'flowchart TD', 'graph LR', 'sequenceDiagram', etc.)."
}
return enhancedMessage
}
// 1) Whenever `code` changes, mark that we need to re-render a new chart
useEffect(() => {
setIsLoading(true)
@ -121,7 +168,8 @@ export default function MermaidBlock({ code }: MermaidBlockProps) {
})
.catch((err) => {
console.warn("Mermaid parse/render failed:", err)
setError(err.message || "Failed to render Mermaid diagram")
const enhancedError = enhanceErrorMessage(err.message || "Failed to render Mermaid diagram", code)
setError(enhancedError)
})
.finally(() => {
setIsLoading(false)
@ -207,7 +255,12 @@ export default function MermaidBlock({ code }: MermaidBlockProps) {
backgroundColor: "var(--vscode-editor-background)",
borderTop: "none",
}}>
<div style={{ marginBottom: "8px", color: "var(--vscode-descriptionForeground)" }}>
<div
style={{
marginBottom: "8px",
color: "var(--vscode-descriptionForeground)",
whiteSpace: "pre-wrap",
}}>
{error}
</div>
<CodeBlock language="mermaid" source={code} />
@ -216,7 +269,11 @@ export default function MermaidBlock({ code }: MermaidBlockProps) {
</div>
) : (
<MermaidButton containerRef={containerRef} code={code} isLoading={isLoading} svgToPng={svgToPng}>
<SvgContainer onClick={handleClick} ref={containerRef} $isLoading={isLoading}></SvgContainer>
<SvgContainer
onClick={handleClick}
ref={containerRef}
$isLoading={isLoading}
data-testid="svg-container"></SvgContainer>
</MermaidButton>
)}
</MermaidBlockContainer>

View file

@ -0,0 +1,287 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { vi } from "vitest"
import mermaid from "mermaid"
import MermaidBlock from "../MermaidBlock"
// Mock mermaid module
vi.mock("mermaid", () => ({
default: {
initialize: vi.fn(),
parse: vi.fn(),
render: vi.fn(),
},
}))
// Mock vscode API
vi.mock("@src/utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
// Mock translation hook
vi.mock("@src/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"common:mermaid.loading": "Loading diagram...",
"common:mermaid.render_error": "Failed to render diagram",
}
return translations[key] || key
},
}),
}))
// Mock clipboard hook
let mockCopyWithFeedback = vi.fn()
vi.mock("@src/utils/clipboard", () => ({
useCopyToClipboard: () => ({
showCopyFeedback: false,
copyWithFeedback: mockCopyWithFeedback,
}),
}))
// Mock CodeBlock component
vi.mock("../CodeBlock", () => ({
default: ({ source, language }: { source: string; language: string }) => (
<div data-testid="code-block" data-language={language}>
{source}
</div>
),
}))
// Mock MermaidButton component
vi.mock("@/components/common/MermaidButton", () => ({
MermaidButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
// Mock canvas API for SVG to PNG conversion
const mockToDataURL = vi.fn(() => "data:image/png;base64,mockpngdata")
const mockGetContext = vi.fn(() => ({
fillStyle: "",
fillRect: vi.fn(),
drawImage: vi.fn(),
imageSmoothingEnabled: true,
imageSmoothingQuality: "high",
}))
HTMLCanvasElement.prototype.toDataURL = mockToDataURL
HTMLCanvasElement.prototype.getContext = mockGetContext as any
describe("MermaidBlock", () => {
beforeEach(() => {
vi.clearAllMocks()
mockCopyWithFeedback = vi.fn()
mockToDataURL.mockClear()
mockGetContext.mockClear()
})
it("renders loading state initially", () => {
vi.mocked(mermaid.parse).mockReturnValue(new Promise(() => {})) // Never resolves
render(<MermaidBlock code="flowchart TD\n A --> B" />)
expect(screen.getByText("Loading diagram...")).toBeInTheDocument()
})
it("renders mermaid diagram successfully", async () => {
const svgContent = "<svg><text>Test Diagram</text></svg>"
vi.mocked(mermaid.parse).mockResolvedValue({} as any)
vi.mocked(mermaid.render).mockResolvedValue({ svg: svgContent } as any)
render(<MermaidBlock code="flowchart TD\n A --> B" />)
await waitFor(() => {
const container = screen.getByTestId("svg-container")
expect(container.innerHTML).toBe(svgContent)
})
})
describe("Error handling", () => {
it("displays error message when mermaid parsing fails", async () => {
const errorMessage = "Parse error on line 2: Expecting 'AMP', 'COLON', got 'LINK_ID'"
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
render(<MermaidBlock code="flowchart TD A[Users Credentials] --> B{AuthController@che" />)
await waitFor(() => {
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
})
})
it("shows enhanced error message for unclosed brackets", async () => {
const errorMessage = "Parse error on line 2: Expecting 'AMP', 'COLON', got 'LINK_ID'"
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
render(<MermaidBlock code="flowchart TD A[Users Credentials --> B" />)
await waitFor(() => {
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
})
// Click to expand error
const errorHeader = screen.getByText("Failed to render diagram").parentElement
await userEvent.click(errorHeader!)
await waitFor(() => {
const errorDetails = screen.getByText(/You have unclosed square brackets/)
expect(errorDetails).toBeInTheDocument()
})
})
it("shows enhanced error message for unclosed braces", async () => {
const errorMessage = "Parse error on line 2: Expecting 'AMP', 'COLON', got 'LINK_ID'"
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
render(<MermaidBlock code="flowchart TD A{Decision --> B" />)
await waitFor(() => {
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
})
// Click to expand error
const errorHeader = screen.getByText("Failed to render diagram").parentElement
await userEvent.click(errorHeader!)
await waitFor(() => {
const errorDetails = screen.getByText(/You have unclosed curly braces/)
expect(errorDetails).toBeInTheDocument()
})
})
it("shows suggestion for incomplete arrow connections", async () => {
const errorMessage = "Parse error at end of input"
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
render(<MermaidBlock code="flowchart TD\n A --> " />)
await waitFor(() => {
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
})
// Click to expand error
const errorHeader = screen.getByText("Failed to render diagram").parentElement
await userEvent.click(errorHeader!)
await waitFor(() => {
const errorDetails = screen.getByText(/Your diagram appears to end with an arrow/)
expect(errorDetails).toBeInTheDocument()
})
})
it("shows suggestion for missing diagram type", async () => {
const errorMessage = "Parse error on line 1"
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
render(<MermaidBlock code="A --> B" />)
await waitFor(() => {
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
})
// Click to expand error
const errorHeader = screen.getByText("Failed to render diagram").parentElement
await userEvent.click(errorHeader!)
await waitFor(() => {
const errorDetails = screen.getByText(/Make sure your diagram starts with a valid diagram type/)
expect(errorDetails).toBeInTheDocument()
})
})
it("shows code block when error is expanded", async () => {
const code = "flowchart TD A[Incomplete"
const errorMessage = "Parse error"
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
render(<MermaidBlock code={code} />)
await waitFor(() => {
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
})
// Click to expand error
const errorHeader = screen.getByText("Failed to render diagram").parentElement
await userEvent.click(errorHeader!)
await waitFor(() => {
const codeBlock = screen.getByTestId("code-block")
expect(codeBlock).toBeInTheDocument()
expect(codeBlock).toHaveAttribute("data-language", "mermaid")
expect(codeBlock).toHaveTextContent(code)
})
})
it("allows copying error message and code", async () => {
const code = "flowchart TD A[Incomplete"
const errorMessage = "Parse error"
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
render(<MermaidBlock code={code} />)
await waitFor(() => {
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
})
// Find and click copy button
const copyButton = screen.getByRole("button")
await userEvent.click(copyButton)
expect(mockCopyWithFeedback).toHaveBeenCalledWith(
expect.stringContaining(`Error: ${errorMessage}`),
expect.any(Object),
)
expect(mockCopyWithFeedback).toHaveBeenCalledWith(expect.stringContaining("```mermaid"), expect.any(Object))
})
})
it("renders mermaid diagram and allows interaction", async () => {
const svgContent = '<svg width="100" height="100"><rect width="100" height="100"></rect></svg>'
vi.mocked(mermaid.parse).mockResolvedValue({} as any)
vi.mocked(mermaid.render).mockResolvedValue({ svg: svgContent } as any)
render(<MermaidBlock code="flowchart TD\n A --> B" />)
await waitFor(() => {
const container = screen.getByTestId("svg-container")
expect(container.innerHTML).toBe(svgContent)
})
// Verify the SVG container is clickable
const svgContainer = screen.getByTestId("svg-container")
expect(svgContainer).toBeInTheDocument()
expect(svgContainer).toHaveStyle({ cursor: "pointer" })
})
it("debounces diagram rendering", async () => {
const { rerender } = render(<MermaidBlock code="flowchart TD\n A --> B" />)
// Initial parse call
expect(mermaid.parse).toHaveBeenCalledTimes(0)
// Wait for debounce
await waitFor(
() => {
expect(mermaid.parse).toHaveBeenCalledTimes(1)
},
{ timeout: 600 },
)
// Quick re-renders should not trigger immediate parse
rerender(<MermaidBlock code="flowchart TD\n A --> C" />)
rerender(<MermaidBlock code="flowchart TD\n A --> D" />)
// Should still be 1 call
expect(mermaid.parse).toHaveBeenCalledTimes(1)
// Wait for new debounce
await waitFor(
() => {
expect(mermaid.parse).toHaveBeenCalledTimes(2)
},
{ timeout: 600 },
)
// Should parse the latest code
expect(mermaid.parse).toHaveBeenLastCalledWith("flowchart TD\\n A --> D")
})
})