markdownContent.test.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  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('Markdown file cards', () => {
  41. it.each([
  42. '/Users/samien/.hermes/workspace/gpt-6-astra-report.md',
  43. '/Users/samien/.hermes/profiles/yaoer/workspace/详细 文档.md',
  44. 'sandbox:/Users/samien/.hermes/workspace/gpt-6-astra-report.md',
  45. '/Users/samien/Agents/report.md',
  46. '/api/app/files/12/download',
  47. ])('renders a preview card for %s', async path => {
  48. const wrapper = mount(MarkdownContent, {
  49. props: { content: `[报告.md](<${path}>)`, fileCards: true },
  50. })
  51. const card = wrapper.get('a.file-link-card')
  52. expect(card.attributes('target')).toBeUndefined()
  53. expect(card.attributes('aria-label')).toBe('预览文件 报告.md')
  54. await card.trigger('click')
  55. expect(wrapper.emitted('fileLink')).toEqual([['报告.md', encodeURI(path.replace(/^sandbox:/, ''))]])
  56. wrapper.unmount()
  57. })
  58. it('preserves ordinary links and keeps unknown protocols sanitized', () => {
  59. const wrapper = mount(MarkdownContent, { props: { fileCards: true, content: [
  60. '[外部](https://example.com/Users/samien/.hermes/workspace/report.md)',
  61. '[普通](https://example.com/report.md)',
  62. '[配置](/Users/samien/.hermes/config.yaml)',
  63. '[未知](sandbox:/etc/passwd)',
  64. ].join('\n\n') } })
  65. expect(wrapper.find('.file-link-card').exists()).toBe(false)
  66. expect(wrapper.find('a[href^="sandbox:"]').exists()).toBe(false)
  67. expect(wrapper.findAll('a[href]')).toHaveLength(3)
  68. wrapper.unmount()
  69. })
  70. it('decorates workspace links arriving in a later message update', async () => {
  71. const wrapper = mount(MarkdownContent, { props: { content: '附件如下:', fileCards: true } })
  72. await wrapper.setProps({ content: '附件如下:\n\n[报告.md](/Users/samien/.hermes/workspace/report.md)' })
  73. expect(wrapper.find('a.file-link-card').exists()).toBe(true)
  74. wrapper.unmount()
  75. })
  76. })
  77. describe('streaming Markdown', () => {
  78. afterEach(() => vi.useRealTimers())
  79. it('formats before completion, preserves settled DOM blocks, and flushes the final token', async () => {
  80. vi.useFakeTimers()
  81. const prefix = '# 流式标题\n\n- 第一项\n- 第二项\n\n```ts\nconst x = '
  82. const wrapper = mount(MarkdownContent, { props: { content: prefix, streaming: true } })
  83. expect(wrapper.get('h1').text()).toBe('流式标题')
  84. expect(wrapper.findAll('li')).toHaveLength(2)
  85. expect(wrapper.get('pre code').element.textContent).toBe('const x = ')
  86. const heading = wrapper.get('h1').element
  87. const list = wrapper.get('ul').element
  88. await wrapper.setProps({ content: `${prefix}42` })
  89. await vi.advanceTimersByTimeAsync(80)
  90. expect(wrapper.get('pre code').text()).toContain('42')
  91. expect(wrapper.get('h1').element).toBe(heading)
  92. expect(wrapper.get('ul').element).toBe(list)
  93. await wrapper.setProps({ content: `${prefix}42\n\x60\x60\x60\n\n最后一个字`, streaming: false })
  94. expect(wrapper.text()).toContain('最后一个字')
  95. expect(wrapper.find('.markdown--streaming').exists()).toBe(false)
  96. await vi.advanceTimersByTimeAsync(100)
  97. expect(wrapper.text()).toContain('最后一个字')
  98. wrapper.unmount()
  99. })
  100. it('does not starve while tokens arrive faster than the render interval', async () => {
  101. vi.useFakeTimers()
  102. const wrapper = mount(MarkdownContent, { props: { content: '# 连续输出\n\n', streaming: true } })
  103. let content = '# 连续输出\n\n'
  104. for (let index = 0; index < 20; index++) {
  105. content += `${index} `
  106. await wrapper.setProps({ content })
  107. await vi.advanceTimersByTimeAsync(10)
  108. if (index === 10) expect(wrapper.text()).toContain('7')
  109. }
  110. await vi.advanceTimersByTimeAsync(80)
  111. expect(wrapper.text()).toContain('19')
  112. wrapper.unmount()
  113. })
  114. it('handles replacement, interruption, literal user text, and unmount during a pending update', async () => {
  115. vi.useFakeTimers()
  116. const wrapper = mount(MarkdownContent, { props: { content: '```swift\nlet x = ', streaming: true } })
  117. await wrapper.setProps({ content: '```swift\nlet x = 1' })
  118. await wrapper.setProps({ streaming: false })
  119. expect(wrapper.get('pre code').text()).toContain('let x = 1')
  120. await wrapper.setProps({ content: '# 另一条消息', streaming: true })
  121. expect(wrapper.get('h1').text()).toBe('另一条消息')
  122. await vi.advanceTimersByTimeAsync(100)
  123. expect(wrapper.find('pre').exists()).toBe(false)
  124. await wrapper.setProps({ content: '**用户原文**', plain: true })
  125. expect(wrapper.get('.plain-text').text()).toBe('**用户原文**')
  126. expect(wrapper.find('strong').exists()).toBe(false)
  127. await wrapper.setProps({ content: '# 新内容', plain: false })
  128. await wrapper.setProps({ content: '# 新内容继续' })
  129. wrapper.unmount()
  130. expect(vi.getTimerCount()).toBe(0)
  131. })
  132. it('renders split tables, links and emphasis safely and preserves the full long reply', async () => {
  133. vi.useFakeTimers()
  134. 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))'
  135. const wrapper = mount(MarkdownContent, { props: { content: '', streaming: true } })
  136. for (let index = 1; index <= source.length; index++) {
  137. await wrapper.setProps({ content: source.slice(0, index) })
  138. await vi.advanceTimersByTimeAsync(80)
  139. }
  140. expect(wrapper.get('strong').text()).toBe('粗体')
  141. expect(wrapper.get('blockquote').text()).toBe('引用')
  142. expect(wrapper.get('table').text()).toContain('项目')
  143. expect(wrapper.get('a').attributes('href')).toBe('https://example.com')
  144. expect(wrapper.find('script').exists()).toBe(false)
  145. expect(wrapper.find('a[href^="javascript:"]').exists()).toBe(false)
  146. const long = `${source}\n\n${'完整的长段落。\n\n'.repeat(400)}最终唯一标记`
  147. await wrapper.setProps({ content: long })
  148. await vi.advanceTimersByTimeAsync(80)
  149. expect(wrapper.text()).toContain('最终唯一标记')
  150. await wrapper.setProps({ streaming: false })
  151. expect(wrapper.text()).toContain('最终唯一标记')
  152. expect(wrapper.emitted('rendered')?.length).toBeGreaterThan(1)
  153. wrapper.unmount()
  154. })
  155. })