fix(markdown): 安全渲染 Mermaid code fence (#229)
* fix(markdown): render mermaid fences safely * chore: drop local smoke screenshot asset
This commit is contained in:
@@ -98,6 +98,7 @@
|
||||
"koa-send": "^5.0.1",
|
||||
"koa-static": "^5.0.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"mermaid": "^11.14.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"naive-ui": "^2.44.1",
|
||||
"nodemon": "^3.1.14",
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import type MarkdownIt from 'markdown-it'
|
||||
import MarkdownItConstructor from 'markdown-it'
|
||||
import { handleCodeBlockCopyClick, renderHighlightedCodeBlock } from './highlight'
|
||||
import { repairNestedMarkdownFences } from './markdownFenceRepair'
|
||||
import {
|
||||
MERMAID_MAX_DIAGRAMS_PER_MESSAGE,
|
||||
MERMAID_MAX_SOURCE_LENGTH,
|
||||
MERMAID_RENDER_TIMEOUT_MS,
|
||||
decodeMermaidSource,
|
||||
isMermaidFence,
|
||||
renderMermaidPlaceholder,
|
||||
} from './mermaidRenderer'
|
||||
import { downloadFile } from '@/api/hermes/download'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -17,7 +26,7 @@ const props = withDefaults(defineProps<{
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
|
||||
const md: MarkdownIt = new MarkdownIt({
|
||||
const md: MarkdownIt = new MarkdownItConstructor({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
@@ -26,6 +35,26 @@ const md: MarkdownIt = new MarkdownIt({
|
||||
},
|
||||
})
|
||||
|
||||
const defaultFenceRenderer = md.renderer.rules.fence?.bind(md.renderer.rules)
|
||||
|
||||
md.renderer.rules.fence = (tokens, idx, options, env, self) => {
|
||||
const token = tokens[idx]
|
||||
if (isMermaidFence(token.info)) {
|
||||
return renderMermaidPlaceholder(token.content)
|
||||
}
|
||||
|
||||
if (defaultFenceRenderer) {
|
||||
return defaultFenceRenderer(tokens, idx, options, env, self)
|
||||
}
|
||||
|
||||
return self.renderToken(tokens, idx, options)
|
||||
}
|
||||
|
||||
const markdownBody = ref<HTMLElement | null>(null)
|
||||
const componentId = `hermes-mermaid-${Math.random().toString(36).slice(2)}`
|
||||
let renderGeneration = 0
|
||||
let unmounted = false
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
let html = md.render(repairNestedMarkdownFences(props.content))
|
||||
if (props.mentionNames && props.mentionNames.length > 0) {
|
||||
@@ -36,6 +65,120 @@ const renderedHtml = computed(() => {
|
||||
return html
|
||||
})
|
||||
|
||||
function renderMermaidFallback(element: HTMLElement, source: string): void {
|
||||
element.outerHTML = renderHighlightedCodeBlock(source, 'mermaid', t('common.copy'))
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function cleanupMermaidRenderArtifacts(id: string): void {
|
||||
document.getElementById(id)?.remove()
|
||||
document.getElementById(`d${id}`)?.remove()
|
||||
}
|
||||
|
||||
async function renderMermaidDiagrams(): Promise<void> {
|
||||
const generation = ++renderGeneration
|
||||
await nextTick()
|
||||
|
||||
const root = markdownBody.value
|
||||
if (unmounted || generation !== renderGeneration || !root) return
|
||||
|
||||
const pendingDiagrams = Array.from(root.querySelectorAll<HTMLElement>('[data-mermaid-pending="true"]'))
|
||||
if (pendingDiagrams.length === 0) return
|
||||
|
||||
const diagramsToRender = pendingDiagrams.slice(0, MERMAID_MAX_DIAGRAMS_PER_MESSAGE)
|
||||
const diagramsToFallback = pendingDiagrams.slice(MERMAID_MAX_DIAGRAMS_PER_MESSAGE)
|
||||
|
||||
for (const element of diagramsToFallback) {
|
||||
renderMermaidFallback(element, decodeMermaidSource(element.getAttribute('data-mermaid-source')))
|
||||
}
|
||||
|
||||
const renderCandidates = diagramsToRender
|
||||
.map(element => ({
|
||||
element,
|
||||
source: decodeMermaidSource(element.getAttribute('data-mermaid-source')),
|
||||
}))
|
||||
|
||||
const validDiagrams = [] as typeof renderCandidates
|
||||
for (const candidate of renderCandidates) {
|
||||
if (unmounted || generation !== renderGeneration || !root.contains(candidate.element)) return
|
||||
|
||||
if (!candidate.source || candidate.source.length > MERMAID_MAX_SOURCE_LENGTH) {
|
||||
renderMermaidFallback(candidate.element, candidate.source)
|
||||
continue
|
||||
}
|
||||
|
||||
validDiagrams.push(candidate)
|
||||
}
|
||||
|
||||
if (validDiagrams.length === 0) return
|
||||
|
||||
let mermaid: typeof import('mermaid').default
|
||||
|
||||
try {
|
||||
mermaid = (await withTimeout(import('mermaid'), MERMAID_RENDER_TIMEOUT_MS, 'Mermaid import')).default
|
||||
if (unmounted || generation !== renderGeneration) return
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
})
|
||||
} catch {
|
||||
if (unmounted || generation !== renderGeneration) return
|
||||
for (const { element, source } of validDiagrams) {
|
||||
if (root.contains(element)) {
|
||||
renderMermaidFallback(element, source)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for (const [index, { element, source }] of validDiagrams.entries()) {
|
||||
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
|
||||
|
||||
try {
|
||||
const id = `${componentId}-${generation}-${index}`
|
||||
const result = await withTimeout(mermaid.render(id, source), MERMAID_RENDER_TIMEOUT_MS, 'Mermaid render')
|
||||
cleanupMermaidRenderArtifacts(id)
|
||||
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
|
||||
|
||||
element.removeAttribute('data-mermaid-pending')
|
||||
element.removeAttribute('data-mermaid-source')
|
||||
element.innerHTML = result.svg
|
||||
} catch {
|
||||
cleanupMermaidRenderArtifacts(`${componentId}-${generation}-${index}`)
|
||||
if (unmounted || generation !== renderGeneration || !root.contains(element)) return
|
||||
renderMermaidFallback(element, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void renderMermaidDiagrams()
|
||||
})
|
||||
|
||||
watch(renderedHtml, () => {
|
||||
void renderMermaidDiagrams()
|
||||
}, { flush: 'post' })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unmounted = true
|
||||
renderGeneration += 1
|
||||
})
|
||||
|
||||
function handleMarkdownClick(event: MouseEvent): void {
|
||||
void handleCodeBlockCopyClick(event)
|
||||
|
||||
@@ -69,7 +212,7 @@ function handleMarkdownClick(event: MouseEvent): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-body" v-html="renderedHtml" @click="handleMarkdownClick"></div>
|
||||
<div ref="markdownBody" class="markdown-body" v-html="renderedHtml" @click="handleMarkdownClick"></div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -78,6 +221,9 @@ function handleMarkdownClick(event: MouseEvent): void {
|
||||
.markdown-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: auto;
|
||||
|
||||
p {
|
||||
@@ -162,5 +308,27 @@ function handleMarkdownClick(event: MouseEvent): void {
|
||||
border-top: 1px solid $border-color;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.mermaid-diagram {
|
||||
margin: 10px 0;
|
||||
padding: 14px;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--accent-primary-rgb), 0.04);
|
||||
overflow-x: auto;
|
||||
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid-loading {
|
||||
color: $text-secondary;
|
||||
font-size: 13px;
|
||||
font-family: $font-code;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const MERMAID_LANGUAGE = 'mermaid'
|
||||
|
||||
export const MERMAID_MAX_DIAGRAMS_PER_MESSAGE = 4
|
||||
export const MERMAID_MAX_SOURCE_LENGTH = 20_000
|
||||
export const MERMAID_RENDER_TIMEOUT_MS = 5_000
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
export function getFenceLanguage(info: string | undefined): string {
|
||||
return info?.trim().split(/\s+/)[0]?.toLowerCase() || ''
|
||||
}
|
||||
|
||||
export function isMermaidFence(info: string | undefined): boolean {
|
||||
return getFenceLanguage(info) === MERMAID_LANGUAGE
|
||||
}
|
||||
|
||||
export function encodeMermaidSource(source: string): string {
|
||||
return encodeURIComponent(source)
|
||||
}
|
||||
|
||||
export function decodeMermaidSource(encoded: string | null | undefined): string {
|
||||
if (!encoded) return ''
|
||||
|
||||
try {
|
||||
return decodeURIComponent(encoded)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function renderMermaidPlaceholder(source: string): string {
|
||||
return [
|
||||
'<div class="mermaid-diagram" data-mermaid-pending="true"',
|
||||
` data-mermaid-source="${escapeHtml(encodeMermaidSource(source))}">`,
|
||||
'<div class="mermaid-loading">Rendering Mermaid diagram…</div>',
|
||||
'</div>',
|
||||
].join('')
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
vi.mock('mermaid', () => new Promise(() => {}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', () => ({
|
||||
useMessage: () => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
|
||||
|
||||
async function flushMermaidRender(): Promise<void> {
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
await nextTick()
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('MarkdownRenderer Mermaid import timeout', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('falls back to copyable code when the mermaid dynamic import never settles', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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