markdownContent.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  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', 'gpt-6-astra-report.md'],
  43. ['/Users/samien/.hermes/profiles/yaoer/workspace/详细 文档.md', '详细 文档.md'],
  44. ['sandbox:/Users/samien/.hermes/workspace/gpt-6-astra-report.md', 'gpt-6-astra-report.md'],
  45. ['/Users/samien/Agents/report.md', 'report.md'],
  46. ['/api/app/files/12/download', '报告.md'],
  47. ['/api/app/files/file-ref/download', '报告.md'],
  48. ])('renders a preview card for %s', async (path, expectedName) => {
  49. const wrapper = mount(MarkdownContent, {
  50. props: { content: `[报告.md](<${path}>)`, fileCards: true },
  51. })
  52. const card = wrapper.get('a.file-link-card')
  53. expect(card.attributes('target')).toBeUndefined()
  54. expect(card.attributes('aria-label')).toBe(`预览文件 ${expectedName}`)
  55. await card.trigger('click')
  56. const href = card.attributes('href')!
  57. if (!path.startsWith('/api/')) expect(new URL(href, 'http://localhost').searchParams.get('path')).toBe(path.replace(/^sandbox:/, ''))
  58. else expect(href).toBe(path)
  59. expect(wrapper.emitted('fileLink')).toEqual([[expectedName, href]])
  60. wrapper.unmount()
  61. })
  62. it('preserves external links, routes server files through permissions and sanitizes unknown protocols', () => {
  63. const wrapper = mount(MarkdownContent, { props: { fileCards: true, content: [
  64. '[外部](https://example.com/Users/samien/.hermes/workspace/report.md)',
  65. '[普通](https://example.com/report.md)',
  66. '[config.yaml](/Users/samien/.hermes/config.yaml)',
  67. '[未知](sandbox:/etc/passwd)',
  68. ].join('\n\n') } })
  69. expect(wrapper.findAll('.file-link-card')).toHaveLength(1)
  70. expect(new URL(wrapper.get('.file-link-card').attributes('href')!, 'http://localhost').searchParams.get('path')).toBe('/Users/samien/.hermes/config.yaml')
  71. expect(wrapper.find('a[href^="sandbox:"]').exists()).toBe(false)
  72. expect(wrapper.findAll('a[href]')).toHaveLength(3)
  73. wrapper.unmount()
  74. })
  75. it('uses the verified target instead of descriptive labels to identify and name file cards', () => {
  76. const wrapper = mount(MarkdownContent, { props: { fileCards: true, content: [
  77. '[源程序鉴别材料|Word](/Users/samien/.hermes/workspace/源程序鉴别材料.docx)',
  78. '[源程序鉴别材料|PDF](/Users/samien/.hermes/workspace/源程序鉴别材料.pdf)',
  79. '[信息采集表|数值修订待确认](/Users/samien/.hermes/workspace/信息采集表.xlsx)',
  80. '[修改说明与验证结果](/Users/samien/.hermes/workspace/修改说明.md)',
  81. ].join('\n\n') } })
  82. const cards = wrapper.findAll('.file-link-card')
  83. expect(cards.map(card => card.text())).toEqual([
  84. '源程序鉴别材料.docx',
  85. '源程序鉴别材料.pdf',
  86. '信息采集表.xlsx',
  87. '修改说明.md',
  88. ])
  89. expect(cards.map(card => card.attributes('aria-label'))).toEqual([
  90. '预览文件 源程序鉴别材料.docx',
  91. '预览文件 源程序鉴别材料.pdf',
  92. '预览文件 信息采集表.xlsx',
  93. '预览文件 修改说明.md',
  94. ])
  95. wrapper.unmount()
  96. })
  97. it('only promotes standalone paragraph or list-item file links', () => {
  98. const wrapper = mount(MarkdownContent, { props: { fileCards: true, content: [
  99. '正文中的[报告](/Users/samien/.hermes/workspace/report.pdf)保持行内链接。',
  100. '> [引用报告](/Users/samien/.hermes/workspace/quote.pdf)',
  101. '# [标题报告](/Users/samien/.hermes/workspace/heading.pdf)',
  102. '**[强调报告](/Users/samien/.hermes/workspace/strong.pdf)**',
  103. '| 文件 |\n| --- |\n| [表格报告](/Users/samien/.hermes/workspace/table.pdf) |',
  104. '- [列表报告](/Users/samien/.hermes/workspace/list.pdf)',
  105. ].join('\n\n') } })
  106. expect(wrapper.findAll('.file-link-card')).toHaveLength(1)
  107. expect(wrapper.get('.file-link-card').attributes('aria-label')).toBe('预览文件 list.pdf')
  108. expect(wrapper.findAll('a')).toHaveLength(6)
  109. wrapper.unmount()
  110. })
  111. it('promotes supported standalone output paths only after completion', async () => {
  112. const wrapper = mount(MarkdownContent, {
  113. props: { content: '/Users/samien/.hermes/workspace/final report.pdf', fileCards: true, legacyMedia: true, streaming: true },
  114. })
  115. expect(wrapper.find('.file-link-card').exists()).toBe(false)
  116. await wrapper.setProps({ streaming: false })
  117. expect(wrapper.get('.file-link-card').attributes('aria-label')).toBe('预览文件 final report.pdf')
  118. await wrapper.setProps({ content: '文件在 `/Users/samien/.hermes/workspace/inline.pdf`。' })
  119. expect(wrapper.find('.file-link-card').exists()).toBe(false)
  120. wrapper.unmount()
  121. })
  122. it('promotes a file link that occupies its own soft-break line', () => {
  123. const wrapper = mount(MarkdownContent, { props: {
  124. fileCards: true,
  125. content: '说明文字\n[下载报告](/Users/samien/.hermes/workspace/report.pdf)\n补充文字',
  126. } })
  127. expect(wrapper.get('.file-link-card').attributes('aria-label')).toBe('预览文件 report.pdf')
  128. wrapper.unmount()
  129. })
  130. it('decorates workspace links arriving in a later message update', async () => {
  131. const wrapper = mount(MarkdownContent, { props: { content: '附件如下:', fileCards: true } })
  132. await wrapper.setProps({ content: '附件如下:\n\n[报告.md](/Users/samien/.hermes/workspace/report.md)' })
  133. expect(wrapper.find('a.file-link-card').exists()).toBe(true)
  134. wrapper.unmount()
  135. })
  136. })
  137. describe('streaming Markdown', () => {
  138. afterEach(() => vi.useRealTimers())
  139. it('formats before completion, preserves settled DOM blocks, and flushes the final token', async () => {
  140. vi.useFakeTimers()
  141. const prefix = '# 流式标题\n\n- 第一项\n- 第二项\n\n```ts\nconst x = '
  142. const wrapper = mount(MarkdownContent, { props: { content: prefix, streaming: true } })
  143. expect(wrapper.get('h1').text()).toBe('流式标题')
  144. expect(wrapper.findAll('li')).toHaveLength(2)
  145. expect(wrapper.get('pre code').element.textContent).toBe('const x = ')
  146. const heading = wrapper.get('h1').element
  147. const list = wrapper.get('ul').element
  148. await wrapper.setProps({ content: `${prefix}42` })
  149. await vi.advanceTimersByTimeAsync(80)
  150. expect(wrapper.get('pre code').text()).toContain('42')
  151. expect(wrapper.get('h1').element).toBe(heading)
  152. expect(wrapper.get('ul').element).toBe(list)
  153. await wrapper.setProps({ content: `${prefix}42\n\x60\x60\x60\n\n最后一个字`, streaming: false })
  154. expect(wrapper.text()).toContain('最后一个字')
  155. expect(wrapper.find('.markdown--streaming').exists()).toBe(false)
  156. await vi.advanceTimersByTimeAsync(100)
  157. expect(wrapper.text()).toContain('最后一个字')
  158. wrapper.unmount()
  159. })
  160. it('does not starve while tokens arrive faster than the render interval', async () => {
  161. vi.useFakeTimers()
  162. const wrapper = mount(MarkdownContent, { props: { content: '# 连续输出\n\n', streaming: true } })
  163. let content = '# 连续输出\n\n'
  164. for (let index = 0; index < 20; index++) {
  165. content += `${index} `
  166. await wrapper.setProps({ content })
  167. await vi.advanceTimersByTimeAsync(10)
  168. if (index === 10) expect(wrapper.text()).toContain('7')
  169. }
  170. await vi.advanceTimersByTimeAsync(80)
  171. expect(wrapper.text()).toContain('19')
  172. wrapper.unmount()
  173. })
  174. it('handles replacement, interruption, literal user text, and unmount during a pending update', async () => {
  175. vi.useFakeTimers()
  176. const wrapper = mount(MarkdownContent, { props: { content: '```swift\nlet x = ', streaming: true } })
  177. await wrapper.setProps({ content: '```swift\nlet x = 1' })
  178. await wrapper.setProps({ streaming: false })
  179. expect(wrapper.get('pre code').text()).toContain('let x = 1')
  180. await wrapper.setProps({ content: '# 另一条消息', streaming: true })
  181. expect(wrapper.get('h1').text()).toBe('另一条消息')
  182. await vi.advanceTimersByTimeAsync(100)
  183. expect(wrapper.find('pre').exists()).toBe(false)
  184. await wrapper.setProps({ content: '**用户原文**', plain: true })
  185. expect(wrapper.get('.plain-text').text()).toBe('**用户原文**')
  186. expect(wrapper.find('strong').exists()).toBe(false)
  187. await wrapper.setProps({ content: '# 新内容', plain: false })
  188. await wrapper.setProps({ content: '# 新内容继续' })
  189. wrapper.unmount()
  190. expect(vi.getTimerCount()).toBe(0)
  191. })
  192. it('renders split tables, links and emphasis safely and preserves the full long reply', async () => {
  193. vi.useFakeTimers()
  194. 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))'
  195. const wrapper = mount(MarkdownContent, { props: { content: '', streaming: true } })
  196. for (let index = 1; index <= source.length; index++) {
  197. await wrapper.setProps({ content: source.slice(0, index) })
  198. await vi.advanceTimersByTimeAsync(80)
  199. }
  200. expect(wrapper.get('strong').text()).toBe('粗体')
  201. expect(wrapper.get('blockquote').text()).toBe('引用')
  202. expect(wrapper.get('table').text()).toContain('项目')
  203. expect(wrapper.get('a').attributes('href')).toBe('https://example.com')
  204. expect(wrapper.find('script').exists()).toBe(false)
  205. expect(wrapper.find('a[href^="javascript:"]').exists()).toBe(false)
  206. const long = `${source}\n\n${'完整的长段落。\n\n'.repeat(400)}最终唯一标记`
  207. await wrapper.setProps({ content: long })
  208. await vi.advanceTimersByTimeAsync(80)
  209. expect(wrapper.text()).toContain('最终唯一标记')
  210. await wrapper.setProps({ streaming: false })
  211. expect(wrapper.text()).toContain('最终唯一标记')
  212. expect(wrapper.emitted('rendered')?.length).toBeGreaterThan(1)
  213. wrapper.unmount()
  214. })
  215. })
  216. it('renders process media as text while retaining message media previews', async () => {
  217. const content = '![过程图](/tmp/process.png)\n\n[process.pdf](/Users/test/Agents/process.pdf)'
  218. const process = mount(MarkdownContent, { props: { content, processContent: true, fileCards: true } })
  219. await nextTick()
  220. expect(process.find('img').exists()).toBe(false)
  221. expect(process.find('.file-link-card').exists()).toBe(false)
  222. expect(process.text()).toContain('过程图')
  223. const message = mount(MarkdownContent, { props: { content, fileCards: true } })
  224. await nextTick()
  225. expect(message.find('img').exists()).toBe(true)
  226. expect(message.find('.file-link-card').exists()).toBe(true)
  227. process.unmount()
  228. message.unmount()
  229. })
  230. it('allows Bot text to render at 33ms while keeping the default renderer cadence', async () => {
  231. vi.useFakeTimers()
  232. try {
  233. const bot = mount(MarkdownContent, { props: { content: '开始', streaming: true, streamIntervalMs: 33 } })
  234. const ordinary = mount(MarkdownContent, { props: { content: '开始', streaming: true } })
  235. await bot.setProps({ content: '开始继续' }); await ordinary.setProps({ content: '开始继续' })
  236. await vi.advanceTimersByTimeAsync(33)
  237. expect(bot.text()).toContain('开始继续'); expect(ordinary.text()).not.toContain('继续')
  238. await vi.advanceTimersByTimeAsync(47)
  239. expect(ordinary.text()).toContain('开始继续')
  240. bot.unmount(); ordinary.unmount()
  241. } finally { vi.useRealTimers() }
  242. })
  243. it('renders server-batched Bot updates without a second throttle', async () => {
  244. vi.useFakeTimers()
  245. try {
  246. const wrapper = mount(MarkdownContent, { props: { content: '开始', streaming: true, streamIntervalMs: 0 } })
  247. await wrapper.setProps({ content: '开始第一批' })
  248. expect(wrapper.text()).toContain('第一批')
  249. await wrapper.setProps({ content: '开始第一批第二批' })
  250. expect(wrapper.text()).toContain('第二批')
  251. wrapper.unmount()
  252. } finally { vi.useRealTimers() }
  253. })