markdownContent.test.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. import { mount } from '@vue/test-utils'
  2. import { nextTick } from 'vue'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import MarkdownContent from '@/components/messages/MarkdownContent.vue'
  5. afterEach(() => {
  6. vi.restoreAllMocks()
  7. Reflect.deleteProperty(navigator, 'clipboard')
  8. Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false })
  9. Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined })
  10. })
  11. describe('MarkdownContent code copy', () => {
  12. it('copies code through the compatibility fallback and confirms success', async () => {
  13. Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true })
  14. Object.defineProperty(navigator, 'clipboard', {
  15. configurable: true,
  16. value: { writeText: vi.fn(async () => { throw new Error('NotAllowedError') }) },
  17. })
  18. let selectedText = ''
  19. Object.defineProperty(document, 'execCommand', {
  20. configurable: true,
  21. value: vi.fn(() => {
  22. selectedText = (document.activeElement as HTMLTextAreaElement | null)?.value ?? ''
  23. return true
  24. }),
  25. })
  26. const wrapper = mount(MarkdownContent, {
  27. attachTo: document.body,
  28. props: { content: '```bash\necho "复制成功"\n```' },
  29. })
  30. await nextTick()
  31. const button = wrapper.find<HTMLButtonElement>('.code-copy')
  32. expect(button.exists()).toBe(true)
  33. await button.trigger('click')
  34. await nextTick()
  35. expect(selectedText).toBe('echo "复制成功"\n')
  36. expect(button.text()).toBe('已复制')
  37. wrapper.unmount()
  38. })
  39. })
  40. describe('streaming Markdown', () => {
  41. afterEach(() => vi.useRealTimers())
  42. it('formats before completion, preserves settled DOM blocks, and flushes the final token', async () => {
  43. vi.useFakeTimers()
  44. const prefix = '# 流式标题\n\n- 第一项\n- 第二项\n\n```ts\nconst x = '
  45. const wrapper = mount(MarkdownContent, { props: { content: prefix, streaming: true } })
  46. expect(wrapper.get('h1').text()).toBe('流式标题')
  47. expect(wrapper.findAll('li')).toHaveLength(2)
  48. expect(wrapper.get('pre code').element.textContent).toBe('const x = ')
  49. const heading = wrapper.get('h1').element
  50. const list = wrapper.get('ul').element
  51. await wrapper.setProps({ content: `${prefix}42` })
  52. await vi.advanceTimersByTimeAsync(80)
  53. expect(wrapper.get('pre code').text()).toContain('42')
  54. expect(wrapper.get('h1').element).toBe(heading)
  55. expect(wrapper.get('ul').element).toBe(list)
  56. await wrapper.setProps({ content: `${prefix}42\n\x60\x60\x60\n\n最后一个字`, streaming: false })
  57. expect(wrapper.text()).toContain('最后一个字')
  58. expect(wrapper.find('.markdown--streaming').exists()).toBe(false)
  59. await vi.advanceTimersByTimeAsync(100)
  60. expect(wrapper.text()).toContain('最后一个字')
  61. wrapper.unmount()
  62. })
  63. it('does not starve while tokens arrive faster than the render interval', async () => {
  64. vi.useFakeTimers()
  65. const wrapper = mount(MarkdownContent, { props: { content: '# 连续输出\n\n', streaming: true } })
  66. let content = '# 连续输出\n\n'
  67. for (let index = 0; index < 20; index++) {
  68. content += `${index} `
  69. await wrapper.setProps({ content })
  70. await vi.advanceTimersByTimeAsync(10)
  71. if (index === 10) expect(wrapper.text()).toContain('7')
  72. }
  73. await vi.advanceTimersByTimeAsync(80)
  74. expect(wrapper.text()).toContain('19')
  75. wrapper.unmount()
  76. })
  77. it('handles replacement, interruption, literal user text, and unmount during a pending update', async () => {
  78. vi.useFakeTimers()
  79. const wrapper = mount(MarkdownContent, { props: { content: '```swift\nlet x = ', streaming: true } })
  80. await wrapper.setProps({ content: '```swift\nlet x = 1' })
  81. await wrapper.setProps({ streaming: false })
  82. expect(wrapper.get('pre code').text()).toContain('let x = 1')
  83. await wrapper.setProps({ content: '# 另一条消息', streaming: true })
  84. expect(wrapper.get('h1').text()).toBe('另一条消息')
  85. await vi.advanceTimersByTimeAsync(100)
  86. expect(wrapper.find('pre').exists()).toBe(false)
  87. await wrapper.setProps({ content: '**用户原文**', plain: true })
  88. expect(wrapper.get('.plain-text').text()).toBe('**用户原文**')
  89. expect(wrapper.find('strong').exists()).toBe(false)
  90. await wrapper.setProps({ content: '# 新内容', plain: false })
  91. await wrapper.setProps({ content: '# 新内容继续' })
  92. wrapper.unmount()
  93. expect(vi.getTimerCount()).toBe(0)
  94. })
  95. it('renders split tables, links and emphasis safely and preserves the full long reply', async () => {
  96. vi.useFakeTimers()
  97. const source = '# 标题\n\n**粗体** 和 `行内代码`\n\n> 引用\n\n| 名称 | 数量 |\n| --- | --- |\n| 项目 | 2 |\n\n[链接](https://example.com)\n\n<script>alert(1)</script>\n\n[危险](javascript:alert(1))'
  98. const wrapper = mount(MarkdownContent, { props: { content: '', streaming: true } })
  99. for (let index = 1; index <= source.length; index++) {
  100. await wrapper.setProps({ content: source.slice(0, index) })
  101. await vi.advanceTimersByTimeAsync(80)
  102. }
  103. expect(wrapper.get('strong').text()).toBe('粗体')
  104. expect(wrapper.get('blockquote').text()).toBe('引用')
  105. expect(wrapper.get('table').text()).toContain('项目')
  106. expect(wrapper.get('a').attributes('href')).toBe('https://example.com')
  107. expect(wrapper.find('script').exists()).toBe(false)
  108. expect(wrapper.find('a[href^="javascript:"]').exists()).toBe(false)
  109. const long = `${source}\n\n${'完整的长段落。\n\n'.repeat(400)}最终唯一标记`
  110. await wrapper.setProps({ content: long })
  111. await vi.advanceTimersByTimeAsync(80)
  112. expect(wrapper.text()).toContain('最终唯一标记')
  113. await wrapper.setProps({ streaming: false })
  114. expect(wrapper.text()).toContain('最终唯一标记')
  115. expect(wrapper.emitted('rendered')?.length).toBeGreaterThan(1)
  116. wrapper.unmount()
  117. })
  118. })