Skip to content

Commit 0f55ac9

Browse files
committed
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
1 parent d90bab7 commit 0f55ac9

File tree

2 files changed

+347
-3
lines changed

2 files changed

+347
-3
lines changed

webview-ui/src/components/common/MermaidBlock.tsx

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,53 @@ export default function MermaidBlock({ code }: MermaidBlockProps) {
9595
const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
9696
const { t } = useAppTranslation()
9797

98+
// Helper function to enhance error messages with suggestions
99+
const enhanceErrorMessage = (originalError: string, code: string): string => {
100+
let enhancedMessage = originalError
101+
102+
// Check for common syntax errors
103+
if (originalError.includes("LINK_ID") && originalError.includes("Expecting")) {
104+
// Count brackets to check for unclosed brackets
105+
const openBrackets = (code.match(/\[/g) || []).length
106+
const closeBrackets = (code.match(/\]/g) || []).length
107+
const openBraces = (code.match(/\{/g) || []).length
108+
const closeBraces = (code.match(/\}/g) || []).length
109+
110+
if (openBrackets > closeBrackets) {
111+
enhancedMessage +=
112+
"\n\nSuggestion: You have unclosed square brackets '['. Make sure all node labels are properly closed with ']'."
113+
}
114+
if (openBraces > closeBraces) {
115+
enhancedMessage +=
116+
"\n\nSuggestion: You have unclosed curly braces '{'. Make sure all decision nodes are properly closed with '}'."
117+
}
118+
119+
// Check for incomplete node definitions
120+
if (code.includes("@") && !code.includes("]") && !code.includes("}")) {
121+
enhancedMessage +=
122+
"\n\nSuggestion: Node labels containing special characters like '@' should be properly enclosed in brackets."
123+
}
124+
}
125+
126+
// Check for other common issues
127+
if (originalError.includes("Parse error") && code.trim().endsWith("-->")) {
128+
enhancedMessage +=
129+
"\n\nSuggestion: Your diagram appears to end with an arrow '-->'. Make sure to complete the connection with a target node."
130+
}
131+
132+
if (
133+
!code.trim().startsWith("graph") &&
134+
!code.trim().startsWith("flowchart") &&
135+
!code.trim().startsWith("sequenceDiagram") &&
136+
!code.trim().startsWith("classDiagram")
137+
) {
138+
enhancedMessage +=
139+
"\n\nSuggestion: Make sure your diagram starts with a valid diagram type (e.g., 'flowchart TD', 'graph LR', 'sequenceDiagram', etc.)."
140+
}
141+
142+
return enhancedMessage
143+
}
144+
98145
// 1) Whenever `code` changes, mark that we need to re-render a new chart
99146
useEffect(() => {
100147
setIsLoading(true)
@@ -121,7 +168,8 @@ export default function MermaidBlock({ code }: MermaidBlockProps) {
121168
})
122169
.catch((err) => {
123170
console.warn("Mermaid parse/render failed:", err)
124-
setError(err.message || "Failed to render Mermaid diagram")
171+
const enhancedError = enhanceErrorMessage(err.message || "Failed to render Mermaid diagram", code)
172+
setError(enhancedError)
125173
})
126174
.finally(() => {
127175
setIsLoading(false)
@@ -207,7 +255,12 @@ export default function MermaidBlock({ code }: MermaidBlockProps) {
207255
backgroundColor: "var(--vscode-editor-background)",
208256
borderTop: "none",
209257
}}>
210-
<div style={{ marginBottom: "8px", color: "var(--vscode-descriptionForeground)" }}>
258+
<div
259+
style={{
260+
marginBottom: "8px",
261+
color: "var(--vscode-descriptionForeground)",
262+
whiteSpace: "pre-wrap",
263+
}}>
211264
{error}
212265
</div>
213266
<CodeBlock language="mermaid" source={code} />
@@ -216,7 +269,11 @@ export default function MermaidBlock({ code }: MermaidBlockProps) {
216269
</div>
217270
) : (
218271
<MermaidButton containerRef={containerRef} code={code} isLoading={isLoading} svgToPng={svgToPng}>
219-
<SvgContainer onClick={handleClick} ref={containerRef} $isLoading={isLoading}></SvgContainer>
272+
<SvgContainer
273+
onClick={handleClick}
274+
ref={containerRef}
275+
$isLoading={isLoading}
276+
data-testid="svg-container"></SvgContainer>
220277
</MermaidButton>
221278
)}
222279
</MermaidBlockContainer>
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
import { render, screen, waitFor } from "@testing-library/react"
2+
import userEvent from "@testing-library/user-event"
3+
import { vi } from "vitest"
4+
import mermaid from "mermaid"
5+
import MermaidBlock from "../MermaidBlock"
6+
7+
// Mock mermaid module
8+
vi.mock("mermaid", () => ({
9+
default: {
10+
initialize: vi.fn(),
11+
parse: vi.fn(),
12+
render: vi.fn(),
13+
},
14+
}))
15+
16+
// Mock vscode API
17+
vi.mock("@src/utils/vscode", () => ({
18+
vscode: {
19+
postMessage: vi.fn(),
20+
},
21+
}))
22+
23+
// Mock translation hook
24+
vi.mock("@src/i18n/TranslationContext", () => ({
25+
useAppTranslation: () => ({
26+
t: (key: string) => {
27+
const translations: Record<string, string> = {
28+
"common:mermaid.loading": "Loading diagram...",
29+
"common:mermaid.render_error": "Failed to render diagram",
30+
}
31+
return translations[key] || key
32+
},
33+
}),
34+
}))
35+
36+
// Mock clipboard hook
37+
let mockCopyWithFeedback = vi.fn()
38+
vi.mock("@src/utils/clipboard", () => ({
39+
useCopyToClipboard: () => ({
40+
showCopyFeedback: false,
41+
copyWithFeedback: mockCopyWithFeedback,
42+
}),
43+
}))
44+
45+
// Mock CodeBlock component
46+
vi.mock("../CodeBlock", () => ({
47+
default: ({ source, language }: { source: string; language: string }) => (
48+
<div data-testid="code-block" data-language={language}>
49+
{source}
50+
</div>
51+
),
52+
}))
53+
54+
// Mock MermaidButton component
55+
vi.mock("@/components/common/MermaidButton", () => ({
56+
MermaidButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
57+
}))
58+
59+
// Mock canvas API for SVG to PNG conversion
60+
const mockToDataURL = vi.fn(() => "data:image/png;base64,mockpngdata")
61+
const mockGetContext = vi.fn(() => ({
62+
fillStyle: "",
63+
fillRect: vi.fn(),
64+
drawImage: vi.fn(),
65+
imageSmoothingEnabled: true,
66+
imageSmoothingQuality: "high",
67+
}))
68+
69+
HTMLCanvasElement.prototype.toDataURL = mockToDataURL
70+
HTMLCanvasElement.prototype.getContext = mockGetContext as any
71+
72+
describe("MermaidBlock", () => {
73+
beforeEach(() => {
74+
vi.clearAllMocks()
75+
mockCopyWithFeedback = vi.fn()
76+
mockToDataURL.mockClear()
77+
mockGetContext.mockClear()
78+
})
79+
80+
it("renders loading state initially", () => {
81+
vi.mocked(mermaid.parse).mockReturnValue(new Promise(() => {})) // Never resolves
82+
render(<MermaidBlock code="flowchart TD\n A --> B" />)
83+
expect(screen.getByText("Loading diagram...")).toBeInTheDocument()
84+
})
85+
86+
it("renders mermaid diagram successfully", async () => {
87+
const svgContent = "<svg><text>Test Diagram</text></svg>"
88+
vi.mocked(mermaid.parse).mockResolvedValue({} as any)
89+
vi.mocked(mermaid.render).mockResolvedValue({ svg: svgContent } as any)
90+
91+
render(<MermaidBlock code="flowchart TD\n A --> B" />)
92+
93+
await waitFor(() => {
94+
const container = screen.getByTestId("svg-container")
95+
expect(container.innerHTML).toBe(svgContent)
96+
})
97+
})
98+
99+
describe("Error handling", () => {
100+
it("displays error message when mermaid parsing fails", async () => {
101+
const errorMessage = "Parse error on line 2: Expecting 'AMP', 'COLON', got 'LINK_ID'"
102+
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
103+
104+
render(<MermaidBlock code="flowchart TD A[Users Credentials] --> B{AuthController@che" />)
105+
106+
await waitFor(() => {
107+
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
108+
})
109+
})
110+
111+
it("shows enhanced error message for unclosed brackets", async () => {
112+
const errorMessage = "Parse error on line 2: Expecting 'AMP', 'COLON', got 'LINK_ID'"
113+
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
114+
115+
render(<MermaidBlock code="flowchart TD A[Users Credentials --> B" />)
116+
117+
await waitFor(() => {
118+
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
119+
})
120+
121+
// Click to expand error
122+
const errorHeader = screen.getByText("Failed to render diagram").parentElement
123+
await userEvent.click(errorHeader!)
124+
125+
await waitFor(() => {
126+
const errorDetails = screen.getByText(/You have unclosed square brackets/)
127+
expect(errorDetails).toBeInTheDocument()
128+
})
129+
})
130+
131+
it("shows enhanced error message for unclosed braces", async () => {
132+
const errorMessage = "Parse error on line 2: Expecting 'AMP', 'COLON', got 'LINK_ID'"
133+
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
134+
135+
render(<MermaidBlock code="flowchart TD A{Decision --> B" />)
136+
137+
await waitFor(() => {
138+
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
139+
})
140+
141+
// Click to expand error
142+
const errorHeader = screen.getByText("Failed to render diagram").parentElement
143+
await userEvent.click(errorHeader!)
144+
145+
await waitFor(() => {
146+
const errorDetails = screen.getByText(/You have unclosed curly braces/)
147+
expect(errorDetails).toBeInTheDocument()
148+
})
149+
})
150+
151+
it("shows suggestion for incomplete arrow connections", async () => {
152+
const errorMessage = "Parse error at end of input"
153+
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
154+
155+
render(<MermaidBlock code="flowchart TD\n A --> " />)
156+
157+
await waitFor(() => {
158+
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
159+
})
160+
161+
// Click to expand error
162+
const errorHeader = screen.getByText("Failed to render diagram").parentElement
163+
await userEvent.click(errorHeader!)
164+
165+
await waitFor(() => {
166+
const errorDetails = screen.getByText(/Your diagram appears to end with an arrow/)
167+
expect(errorDetails).toBeInTheDocument()
168+
})
169+
})
170+
171+
it("shows suggestion for missing diagram type", async () => {
172+
const errorMessage = "Parse error on line 1"
173+
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
174+
175+
render(<MermaidBlock code="A --> B" />)
176+
177+
await waitFor(() => {
178+
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
179+
})
180+
181+
// Click to expand error
182+
const errorHeader = screen.getByText("Failed to render diagram").parentElement
183+
await userEvent.click(errorHeader!)
184+
185+
await waitFor(() => {
186+
const errorDetails = screen.getByText(/Make sure your diagram starts with a valid diagram type/)
187+
expect(errorDetails).toBeInTheDocument()
188+
})
189+
})
190+
191+
it("shows code block when error is expanded", async () => {
192+
const code = "flowchart TD A[Incomplete"
193+
const errorMessage = "Parse error"
194+
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
195+
196+
render(<MermaidBlock code={code} />)
197+
198+
await waitFor(() => {
199+
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
200+
})
201+
202+
// Click to expand error
203+
const errorHeader = screen.getByText("Failed to render diagram").parentElement
204+
await userEvent.click(errorHeader!)
205+
206+
await waitFor(() => {
207+
const codeBlock = screen.getByTestId("code-block")
208+
expect(codeBlock).toBeInTheDocument()
209+
expect(codeBlock).toHaveAttribute("data-language", "mermaid")
210+
expect(codeBlock).toHaveTextContent(code)
211+
})
212+
})
213+
214+
it("allows copying error message and code", async () => {
215+
const code = "flowchart TD A[Incomplete"
216+
const errorMessage = "Parse error"
217+
vi.mocked(mermaid.parse).mockRejectedValue(new Error(errorMessage))
218+
219+
render(<MermaidBlock code={code} />)
220+
221+
await waitFor(() => {
222+
expect(screen.getByText("Failed to render diagram")).toBeInTheDocument()
223+
})
224+
225+
// Find and click copy button
226+
const copyButton = screen.getByRole("button")
227+
await userEvent.click(copyButton)
228+
229+
expect(mockCopyWithFeedback).toHaveBeenCalledWith(
230+
expect.stringContaining(`Error: ${errorMessage}`),
231+
expect.any(Object),
232+
)
233+
expect(mockCopyWithFeedback).toHaveBeenCalledWith(expect.stringContaining("```mermaid"), expect.any(Object))
234+
})
235+
})
236+
237+
it("renders mermaid diagram and allows interaction", async () => {
238+
const svgContent = '<svg width="100" height="100"><rect width="100" height="100"></rect></svg>'
239+
vi.mocked(mermaid.parse).mockResolvedValue({} as any)
240+
vi.mocked(mermaid.render).mockResolvedValue({ svg: svgContent } as any)
241+
242+
render(<MermaidBlock code="flowchart TD\n A --> B" />)
243+
244+
await waitFor(() => {
245+
const container = screen.getByTestId("svg-container")
246+
expect(container.innerHTML).toBe(svgContent)
247+
})
248+
249+
// Verify the SVG container is clickable
250+
const svgContainer = screen.getByTestId("svg-container")
251+
expect(svgContainer).toBeInTheDocument()
252+
expect(svgContainer).toHaveStyle({ cursor: "pointer" })
253+
})
254+
255+
it("debounces diagram rendering", async () => {
256+
const { rerender } = render(<MermaidBlock code="flowchart TD\n A --> B" />)
257+
258+
// Initial parse call
259+
expect(mermaid.parse).toHaveBeenCalledTimes(0)
260+
261+
// Wait for debounce
262+
await waitFor(
263+
() => {
264+
expect(mermaid.parse).toHaveBeenCalledTimes(1)
265+
},
266+
{ timeout: 600 },
267+
)
268+
269+
// Quick re-renders should not trigger immediate parse
270+
rerender(<MermaidBlock code="flowchart TD\n A --> C" />)
271+
rerender(<MermaidBlock code="flowchart TD\n A --> D" />)
272+
273+
// Should still be 1 call
274+
expect(mermaid.parse).toHaveBeenCalledTimes(1)
275+
276+
// Wait for new debounce
277+
await waitFor(
278+
() => {
279+
expect(mermaid.parse).toHaveBeenCalledTimes(2)
280+
},
281+
{ timeout: 600 },
282+
)
283+
284+
// Should parse the latest code
285+
expect(mermaid.parse).toHaveBeenLastCalledWith("flowchart TD\\n A --> D")
286+
})
287+
})

0 commit comments

Comments
 (0)