fix(markdown): 安全渲染 Mermaid code fence (#229)
* fix(markdown): render mermaid fences safely * chore: drop local smoke screenshot asset
This commit is contained in:
@@ -1,6 +1,25 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const mermaidMock = vi.hoisted(() => ({
|
||||
initialize: vi.fn(),
|
||||
render: vi.fn(async (id: string, source: string) => ({
|
||||
svg: `<svg id="${id}" data-testid="mermaid-svg"><text>${source}</text></svg>`,
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('mermaid', () => ({
|
||||
default: mermaidMock,
|
||||
}))
|
||||
|
||||
async function flushMermaidRender(): Promise<void> {
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
@@ -20,7 +39,17 @@ vi.mock('naive-ui', () => ({
|
||||
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
|
||||
describe('MarkdownRenderer', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
mermaidMock.initialize.mockClear()
|
||||
mermaidMock.render.mockClear()
|
||||
mermaidMock.render.mockImplementation(async (id: string, source: string) => ({
|
||||
svg: `<svg id="${id}" data-testid="mermaid-svg"><text>${source}</text></svg>`,
|
||||
}))
|
||||
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -235,6 +264,195 @@ describe('MarkdownRenderer', () => {
|
||||
expect(wrapper.find('.markdown-body').text()).toContain('Done outside.')
|
||||
})
|
||||
|
||||
it('renders mermaid fences as diagrams instead of raw highlighted code', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: [
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
'A[User] --> B[Web UI<br/>command]',
|
||||
'```',
|
||||
'',
|
||||
'具体 behavior:',
|
||||
'- Markdown below still renders.',
|
||||
].join('\n'),
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(mermaidMock.initialize).toHaveBeenCalledWith(expect.objectContaining({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
}))
|
||||
expect(mermaidMock.render).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^hermes-mermaid-/),
|
||||
expect.stringContaining('flowchart TD'),
|
||||
)
|
||||
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.hljs-code-block')).toHaveLength(0)
|
||||
expect(wrapper.find('.markdown-body').find('ul').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders mermaid inside repaired outer markdown draft fences', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: [
|
||||
'```md',
|
||||
'## Command flow',
|
||||
'',
|
||||
'```Mermaid title',
|
||||
'flowchart LR',
|
||||
'A --> B',
|
||||
'```',
|
||||
'',
|
||||
'Done outside.',
|
||||
'```',
|
||||
].join('\n'),
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(wrapper.find('.markdown-body').find('h2').text()).toBe('Command flow')
|
||||
expect(mermaidMock.render).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^hermes-mermaid-/),
|
||||
expect.stringContaining('flowchart LR'),
|
||||
)
|
||||
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.markdown-body').text()).toContain('Done outside.')
|
||||
})
|
||||
|
||||
it('falls back to a copyable code block when mermaid rendering fails', async () => {
|
||||
mermaidMock.render.mockImplementationOnce((id: string) => {
|
||||
const errorContainer = document.createElement('div')
|
||||
errorContainer.id = `d${id}`
|
||||
errorContainer.textContent = 'Syntax error in text\nmermaid version 11.14.0'
|
||||
document.body.appendChild(errorContainer)
|
||||
return Promise.reject(new Error('bad diagram'))
|
||||
})
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```mermaid\nnot valid mermaid\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
|
||||
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
|
||||
expect(wrapper.find('code.hljs').text()).toContain('not valid mermaid')
|
||||
expect(wrapper.find('[data-copy-code="true"]').exists()).toBe(true)
|
||||
expect(document.body.textContent).not.toContain('Syntax error in text')
|
||||
})
|
||||
|
||||
it('falls back to copyable code blocks when mermaid initialization fails', async () => {
|
||||
mermaidMock.initialize.mockImplementationOnce(() => {
|
||||
throw new Error('init failed')
|
||||
})
|
||||
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```mermaid\nflowchart TD\nA --> B\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(mermaidMock.render).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
|
||||
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
|
||||
expect(wrapper.find('code.hljs').text()).toContain('flowchart TD')
|
||||
})
|
||||
|
||||
it('falls back without initializing mermaid when every pending diagram is oversized', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: `\`\`\`mermaid\n${'A'.repeat(20_001)}\n\`\`\``,
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(mermaidMock.initialize).not.toHaveBeenCalled()
|
||||
expect(mermaidMock.render).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
|
||||
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
|
||||
})
|
||||
|
||||
it('falls back without initializing mermaid when every pending diagram is empty', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```mermaid\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(mermaidMock.initialize).not.toHaveBeenCalled()
|
||||
expect(mermaidMock.render).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
|
||||
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
|
||||
})
|
||||
|
||||
it('falls back to copyable code when mermaid rendering never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
mermaidMock.render.mockImplementationOnce(() => new Promise(() => {}))
|
||||
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```mermaid\nflowchart TD\nA --> B\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(5_001)
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(wrapper.find('.mermaid-loading').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="mermaid-svg"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.hljs-code-block').exists()).toBe(true)
|
||||
expect(wrapper.find('.code-lang').text()).toBe('mermaid')
|
||||
expect(wrapper.find('code.hljs').text()).toContain('flowchart TD')
|
||||
})
|
||||
|
||||
it('does not load or render mermaid when the message has no mermaid block', async () => {
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```ts\nconst answer = 42\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(mermaidMock.initialize).not.toHaveBeenCalled()
|
||||
expect(mermaidMock.render).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.code-lang').text()).toBe('ts')
|
||||
})
|
||||
|
||||
it('does not let stale async mermaid renders mutate newer message content', async () => {
|
||||
let resolveRender: ((value: { svg: string }) => void) | undefined
|
||||
mermaidMock.render.mockImplementationOnce((id: string) => new Promise(resolve => {
|
||||
resolveRender = resolve
|
||||
}))
|
||||
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
props: {
|
||||
content: '```mermaid\nflowchart TD\nA --> B\n```',
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
await wrapper.setProps({ content: 'No diagram now.' })
|
||||
resolveRender?.({ svg: '<svg data-testid="stale-mermaid-svg"></svg>' })
|
||||
await flushMermaidRender()
|
||||
|
||||
expect(wrapper.find('[data-testid="stale-mermaid-svg"]').exists()).toBe(false)
|
||||
expect(wrapper.find('.markdown-body').text()).toContain('No diagram now.')
|
||||
})
|
||||
|
||||
it('copies code through the delegated click handler', async () => {
|
||||
const writeText = vi.mocked(navigator.clipboard.writeText)
|
||||
const wrapper = mount(MarkdownRenderer, {
|
||||
|
||||
Reference in New Issue
Block a user