markdownContent.test.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  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. const href = card.attributes('href')!
  56. if (!path.startsWith('/api/')) expect(new URL(href, 'http://localhost').searchParams.get('path')).toBe(path.replace(/^sandbox:/, ''))
  57. else expect(href).toBe(path)
  58. expect(wrapper.emitted('fileLink')).toEqual([['报告.md', href]])
  59. wrapper.unmount()
  60. })
  61. it('preserves external links, routes server files through permissions and sanitizes unknown protocols', () => {
  62. const wrapper = mount(MarkdownContent, { props: { fileCards: true, content: [
  63. '[外部](https://example.com/Users/samien/.hermes/workspace/report.md)',
  64. '[普通](https://example.com/report.md)',
  65. '[配置](/Users/samien/.hermes/config.yaml)',
  66. '[未知](sandbox:/etc/passwd)',
  67. ].join('\n\n') } })
  68. expect(wrapper.findAll('.file-link-card')).toHaveLength(1)
  69. expect(new URL(wrapper.get('.file-link-card').attributes('href')!, 'http://localhost').searchParams.get('path')).toBe('/Users/samien/.hermes/config.yaml')
  70. expect(wrapper.find('a[href^="sandbox:"]').exists()).toBe(false)
  71. expect(wrapper.findAll('a[href]')).toHaveLength(3)
  72. wrapper.unmount()
  73. })
  74. it('decorates workspace links arriving in a later message update', async () => {
  75. const wrapper = mount(MarkdownContent, { props: { content: '附件如下:', fileCards: true } })
  76. await wrapper.setProps({ content: '附件如下:\n\n[报告.md](/Users/samien/.hermes/workspace/report.md)' })
  77. expect(wrapper.find('a.file-link-card').exists()).toBe(true)
  78. wrapper.unmount()
  79. })
  80. })
  81. describe('streaming Markdown', () => {
  82. afterEach(() => vi.useRealTimers())
  83. it('formats before completion, preserves settled DOM blocks, and flushes the final token', async () => {
  84. vi.useFakeTimers()
  85. const prefix = '# 流式标题\n\n- 第一项\n- 第二项\n\n```ts\nconst x = '
  86. const wrapper = mount(MarkdownContent, { props: { content: prefix, streaming: true } })
  87. expect(wrapper.get('h1').text()).toBe('流式标题')
  88. expect(wrapper.findAll('li')).toHaveLength(2)
  89. expect(wrapper.get('pre code').element.textContent).toBe('const x = ')
  90. const heading = wrapper.get('h1').element
  91. const list = wrapper.get('ul').element
  92. await wrapper.setProps({ content: `${prefix}42` })
  93. await vi.advanceTimersByTimeAsync(80)
  94. expect(wrapper.get('pre code').text()).toContain('42')
  95. expect(wrapper.get('h1').element).toBe(heading)
  96. expect(wrapper.get('ul').element).toBe(list)
  97. await wrapper.setProps({ content: `${prefix}42\n\x60\x60\x60\n\n最后一个字`, streaming: false })
  98. expect(wrapper.text()).toContain('最后一个字')
  99. expect(wrapper.find('.markdown--streaming').exists()).toBe(false)
  100. await vi.advanceTimersByTimeAsync(100)
  101. expect(wrapper.text()).toContain('最后一个字')
  102. wrapper.unmount()
  103. })
  104. it('does not starve while tokens arrive faster than the render interval', async () => {
  105. vi.useFakeTimers()
  106. const wrapper = mount(MarkdownContent, { props: { content: '# 连续输出\n\n', streaming: true } })
  107. let content = '# 连续输出\n\n'
  108. for (let index = 0; index < 20; index++) {
  109. content += `${index} `
  110. await wrapper.setProps({ content })
  111. await vi.advanceTimersByTimeAsync(10)
  112. if (index === 10) expect(wrapper.text()).toContain('7')
  113. }
  114. await vi.advanceTimersByTimeAsync(80)
  115. expect(wrapper.text()).toContain('19')
  116. wrapper.unmount()
  117. })
  118. it('handles replacement, interruption, literal user text, and unmount during a pending update', async () => {
  119. vi.useFakeTimers()
  120. const wrapper = mount(MarkdownContent, { props: { content: '```swift\nlet x = ', streaming: true } })
  121. await wrapper.setProps({ content: '```swift\nlet x = 1' })
  122. await wrapper.setProps({ streaming: false })
  123. expect(wrapper.get('pre code').text()).toContain('let x = 1')
  124. await wrapper.setProps({ content: '# 另一条消息', streaming: true })
  125. expect(wrapper.get('h1').text()).toBe('另一条消息')
  126. await vi.advanceTimersByTimeAsync(100)
  127. expect(wrapper.find('pre').exists()).toBe(false)
  128. await wrapper.setProps({ content: '**用户原文**', plain: true })
  129. expect(wrapper.get('.plain-text').text()).toBe('**用户原文**')
  130. expect(wrapper.find('strong').exists()).toBe(false)
  131. await wrapper.setProps({ content: '# 新内容', plain: false })
  132. await wrapper.setProps({ content: '# 新内容继续' })
  133. wrapper.unmount()
  134. expect(vi.getTimerCount()).toBe(0)
  135. })
  136. it('renders split tables, links and emphasis safely and preserves the full long reply', async () => {
  137. vi.useFakeTimers()
  138. 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))'
  139. const wrapper = mount(MarkdownContent, { props: { content: '', streaming: true } })
  140. for (let index = 1; index <= source.length; index++) {
  141. await wrapper.setProps({ content: source.slice(0, index) })
  142. await vi.advanceTimersByTimeAsync(80)
  143. }
  144. expect(wrapper.get('strong').text()).toBe('粗体')
  145. expect(wrapper.get('blockquote').text()).toBe('引用')
  146. expect(wrapper.get('table').text()).toContain('项目')
  147. expect(wrapper.get('a').attributes('href')).toBe('https://example.com')
  148. expect(wrapper.find('script').exists()).toBe(false)
  149. expect(wrapper.find('a[href^="javascript:"]').exists()).toBe(false)
  150. const long = `${source}\n\n${'完整的长段落。\n\n'.repeat(400)}最终唯一标记`
  151. await wrapper.setProps({ content: long })
  152. await vi.advanceTimersByTimeAsync(80)
  153. expect(wrapper.text()).toContain('最终唯一标记')
  154. await wrapper.setProps({ streaming: false })
  155. expect(wrapper.text()).toContain('最终唯一标记')
  156. expect(wrapper.emitted('rendered')?.length).toBeGreaterThan(1)
  157. wrapper.unmount()
  158. })
  159. })
  160. it('renders process media as text while retaining message media previews', async () => {
  161. const content = '![过程图](/tmp/process.png) [过程文件](/Users/test/Agents/process.pdf)'
  162. const process = mount(MarkdownContent, { props: { content, processContent: true, fileCards: true } })
  163. await nextTick()
  164. expect(process.find('img').exists()).toBe(false)
  165. expect(process.find('.file-link-card').exists()).toBe(false)
  166. expect(process.text()).toContain('过程图')
  167. const message = mount(MarkdownContent, { props: { content, fileCards: true } })
  168. await nextTick()
  169. expect(message.find('img').exists()).toBe(true)
  170. expect(message.find('.file-link-card').exists()).toBe(true)
  171. process.unmount()
  172. message.unmount()
  173. })