markdownContent.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  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. '[config.yaml](/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('keeps descriptive file links as ordinary links instead of file cards', () => {
  75. const wrapper = mount(MarkdownContent, { props: { fileCards: true, content: [
  76. '[源程序鉴别材料|Word](/Users/samien/.hermes/workspace/源程序鉴别材料.docx)',
  77. '[源程序鉴别材料|PDF](/Users/samien/.hermes/workspace/源程序鉴别材料.pdf)',
  78. '[信息采集表|数值修订待确认](/Users/samien/.hermes/workspace/信息采集表.xlsx)',
  79. '[修改说明与验证结果](/Users/samien/.hermes/workspace/修改说明.md)',
  80. ].join('\n\n') } })
  81. expect(wrapper.findAll('.file-link-card')).toHaveLength(0)
  82. expect(wrapper.findAll('a')).toHaveLength(4)
  83. expect(wrapper.findAll('a').every(link => link.attributes('target') === '_blank')).toBe(true)
  84. wrapper.unmount()
  85. })
  86. it('decorates workspace links arriving in a later message update', async () => {
  87. const wrapper = mount(MarkdownContent, { props: { content: '附件如下:', fileCards: true } })
  88. await wrapper.setProps({ content: '附件如下:\n\n[报告.md](/Users/samien/.hermes/workspace/report.md)' })
  89. expect(wrapper.find('a.file-link-card').exists()).toBe(true)
  90. wrapper.unmount()
  91. })
  92. })
  93. describe('streaming Markdown', () => {
  94. afterEach(() => vi.useRealTimers())
  95. it('formats before completion, preserves settled DOM blocks, and flushes the final token', async () => {
  96. vi.useFakeTimers()
  97. const prefix = '# 流式标题\n\n- 第一项\n- 第二项\n\n```ts\nconst x = '
  98. const wrapper = mount(MarkdownContent, { props: { content: prefix, streaming: true } })
  99. expect(wrapper.get('h1').text()).toBe('流式标题')
  100. expect(wrapper.findAll('li')).toHaveLength(2)
  101. expect(wrapper.get('pre code').element.textContent).toBe('const x = ')
  102. const heading = wrapper.get('h1').element
  103. const list = wrapper.get('ul').element
  104. await wrapper.setProps({ content: `${prefix}42` })
  105. await vi.advanceTimersByTimeAsync(80)
  106. expect(wrapper.get('pre code').text()).toContain('42')
  107. expect(wrapper.get('h1').element).toBe(heading)
  108. expect(wrapper.get('ul').element).toBe(list)
  109. await wrapper.setProps({ content: `${prefix}42\n\x60\x60\x60\n\n最后一个字`, streaming: false })
  110. expect(wrapper.text()).toContain('最后一个字')
  111. expect(wrapper.find('.markdown--streaming').exists()).toBe(false)
  112. await vi.advanceTimersByTimeAsync(100)
  113. expect(wrapper.text()).toContain('最后一个字')
  114. wrapper.unmount()
  115. })
  116. it('does not starve while tokens arrive faster than the render interval', async () => {
  117. vi.useFakeTimers()
  118. const wrapper = mount(MarkdownContent, { props: { content: '# 连续输出\n\n', streaming: true } })
  119. let content = '# 连续输出\n\n'
  120. for (let index = 0; index < 20; index++) {
  121. content += `${index} `
  122. await wrapper.setProps({ content })
  123. await vi.advanceTimersByTimeAsync(10)
  124. if (index === 10) expect(wrapper.text()).toContain('7')
  125. }
  126. await vi.advanceTimersByTimeAsync(80)
  127. expect(wrapper.text()).toContain('19')
  128. wrapper.unmount()
  129. })
  130. it('handles replacement, interruption, literal user text, and unmount during a pending update', async () => {
  131. vi.useFakeTimers()
  132. const wrapper = mount(MarkdownContent, { props: { content: '```swift\nlet x = ', streaming: true } })
  133. await wrapper.setProps({ content: '```swift\nlet x = 1' })
  134. await wrapper.setProps({ streaming: false })
  135. expect(wrapper.get('pre code').text()).toContain('let x = 1')
  136. await wrapper.setProps({ content: '# 另一条消息', streaming: true })
  137. expect(wrapper.get('h1').text()).toBe('另一条消息')
  138. await vi.advanceTimersByTimeAsync(100)
  139. expect(wrapper.find('pre').exists()).toBe(false)
  140. await wrapper.setProps({ content: '**用户原文**', plain: true })
  141. expect(wrapper.get('.plain-text').text()).toBe('**用户原文**')
  142. expect(wrapper.find('strong').exists()).toBe(false)
  143. await wrapper.setProps({ content: '# 新内容', plain: false })
  144. await wrapper.setProps({ content: '# 新内容继续' })
  145. wrapper.unmount()
  146. expect(vi.getTimerCount()).toBe(0)
  147. })
  148. it('renders split tables, links and emphasis safely and preserves the full long reply', async () => {
  149. vi.useFakeTimers()
  150. 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))'
  151. const wrapper = mount(MarkdownContent, { props: { content: '', streaming: true } })
  152. for (let index = 1; index <= source.length; index++) {
  153. await wrapper.setProps({ content: source.slice(0, index) })
  154. await vi.advanceTimersByTimeAsync(80)
  155. }
  156. expect(wrapper.get('strong').text()).toBe('粗体')
  157. expect(wrapper.get('blockquote').text()).toBe('引用')
  158. expect(wrapper.get('table').text()).toContain('项目')
  159. expect(wrapper.get('a').attributes('href')).toBe('https://example.com')
  160. expect(wrapper.find('script').exists()).toBe(false)
  161. expect(wrapper.find('a[href^="javascript:"]').exists()).toBe(false)
  162. const long = `${source}\n\n${'完整的长段落。\n\n'.repeat(400)}最终唯一标记`
  163. await wrapper.setProps({ content: long })
  164. await vi.advanceTimersByTimeAsync(80)
  165. expect(wrapper.text()).toContain('最终唯一标记')
  166. await wrapper.setProps({ streaming: false })
  167. expect(wrapper.text()).toContain('最终唯一标记')
  168. expect(wrapper.emitted('rendered')?.length).toBeGreaterThan(1)
  169. wrapper.unmount()
  170. })
  171. })
  172. it('renders process media as text while retaining message media previews', async () => {
  173. const content = '![过程图](/tmp/process.png) [process.pdf](/Users/test/Agents/process.pdf)'
  174. const process = mount(MarkdownContent, { props: { content, processContent: true, fileCards: true } })
  175. await nextTick()
  176. expect(process.find('img').exists()).toBe(false)
  177. expect(process.find('.file-link-card').exists()).toBe(false)
  178. expect(process.text()).toContain('过程图')
  179. const message = mount(MarkdownContent, { props: { content, fileCards: true } })
  180. await nextTick()
  181. expect(message.find('img').exists()).toBe(true)
  182. expect(message.find('.file-link-card').exists()).toBe(true)
  183. process.unmount()
  184. message.unmount()
  185. })
  186. it('allows Bot text to render at 33ms while keeping the default renderer cadence', async () => {
  187. vi.useFakeTimers()
  188. try {
  189. const bot = mount(MarkdownContent, { props: { content: '开始', streaming: true, streamIntervalMs: 33 } })
  190. const ordinary = mount(MarkdownContent, { props: { content: '开始', streaming: true } })
  191. await bot.setProps({ content: '开始继续' }); await ordinary.setProps({ content: '开始继续' })
  192. await vi.advanceTimersByTimeAsync(33)
  193. expect(bot.text()).toContain('开始继续'); expect(ordinary.text()).not.toContain('继续')
  194. await vi.advanceTimersByTimeAsync(47)
  195. expect(ordinary.text()).toContain('开始继续')
  196. bot.unmount(); ordinary.unmount()
  197. } finally { vi.useRealTimers() }
  198. })
  199. it('renders server-batched Bot updates without a second throttle', async () => {
  200. vi.useFakeTimers()
  201. try {
  202. const wrapper = mount(MarkdownContent, { props: { content: '开始', streaming: true, streamIntervalMs: 0 } })
  203. await wrapper.setProps({ content: '开始第一批' })
  204. expect(wrapper.text()).toContain('第一批')
  205. await wrapper.setProps({ content: '开始第一批第二批' })
  206. expect(wrapper.text()).toContain('第二批')
  207. wrapper.unmount()
  208. } finally { vi.useRealTimers() }
  209. })