markdown.spec.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. // @vitest-environment jsdom
  2. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  3. import { afterEach, describe, expect, it } from 'vitest'
  4. import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
  5. afterEach(cleanup)
  6. describe('MessageText', () => {
  7. it('renders the text verbatim', () => {
  8. const { container } = render(<MessageText text={'# line1\n`line2`'} />)
  9. expect(container.textContent).toBe('# line1\n`line2`')
  10. expect(container.querySelector('h1')).toBeNull()
  11. })
  12. })
  13. describe('MarkdownText', () => {
  14. it('renders CommonMark and GFM elements as semantic DOM', () => {
  15. const markdown = [
  16. '# Heading',
  17. '',
  18. 'Paragraph with **strong**, *emphasis*, ~~deleted~~, `inline`, and [safe](https://example.com). ',
  19. 'Hard break.',
  20. '',
  21. '> Quote',
  22. '',
  23. '- parent',
  24. ' - child',
  25. '',
  26. '1. first',
  27. '2. second',
  28. '',
  29. '- [x] done',
  30. '- [ ] pending',
  31. '',
  32. '| Name | Value |',
  33. '| --- | --- |',
  34. '| alpha | beta |',
  35. '',
  36. '---',
  37. '',
  38. '```ts',
  39. 'const answer = 42',
  40. '```',
  41. '',
  42. '<https://deepseek.com>',
  43. ].join('\n')
  44. const { container } = render(<MarkdownText text={markdown} />)
  45. expect(screen.getByRole('heading', { level: 1, name: 'Heading' })).toBeTruthy()
  46. expect(container.querySelector('strong')?.textContent).toBe('strong')
  47. expect(container.querySelector('em')?.textContent).toBe('emphasis')
  48. expect(container.querySelector('del')?.textContent).toBe('deleted')
  49. expect(container.querySelector('blockquote')?.textContent?.trim()).toBe('Quote')
  50. expect(container.querySelectorAll('ul')).toHaveLength(3)
  51. expect(container.querySelector('ol')).not.toBeNull()
  52. expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2)
  53. expect(container.querySelector('table')?.textContent).toContain('alphabeta')
  54. expect(container.querySelector('hr')).not.toBeNull()
  55. expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
  56. // The ts fence routed through the shared CodeBlock: shiki token spans + banner.
  57. expect(container.querySelector('pre.shiki')).not.toBeNull()
  58. expect(screen.getByText('ts')).toBeTruthy()
  59. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  60. expect(container.querySelector('br')).not.toBeNull()
  61. expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
  62. expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
  63. })
  64. it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => {
  65. for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) {
  66. const { container, unmount } = render(<MarkdownText text={'```' + label + '\ncode body\n```'} />)
  67. expect(container.querySelector('pre.shiki')).toBeNull()
  68. expect(container.querySelector('pre code')?.textContent).toContain('code body')
  69. unmount()
  70. }
  71. })
  72. it('an empty fence keeps the stock pre; a language-less fence renders the plain CodeBlock arm', () => {
  73. const empty = render(<MarkdownText text={'```\n```'} />)
  74. expect(empty.container.querySelector('pre')?.outerHTML).toBe('<pre><code></code></pre>')
  75. const plain = render(<MarkdownText text={'```\nno language here\n```'} />)
  76. expect(plain.container.querySelector('pre.shiki')).toBeNull()
  77. expect(plain.container.querySelector('pre code')?.textContent).toContain('no language here')
  78. })
  79. it('streaming renders fences plain; the finalize swap highlights them', () => {
  80. const fence = '```ts\nconst answer = 42\n```'
  81. const live = render(<MarkdownText text={fence} streaming />)
  82. expect(live.container.querySelector('pre.shiki')).toBeNull()
  83. expect(live.container.querySelector('pre code')?.textContent).toContain('const answer = 42')
  84. live.unmount()
  85. const done = render(<MarkdownText text={fence} />)
  86. expect(done.container.querySelector('pre.shiki')).not.toBeNull()
  87. })
  88. it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
  89. const markdown = [
  90. '<script>globalThis.compromised = true</script>',
  91. '<img src="x" onerror="globalThis.compromised = true">',
  92. '[script](javascript:alert(1)) [relative](/settings)',
  93. '[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
  94. '![remote diagram](https://example.com/private.png)',
  95. ].join('\n\n')
  96. const { container } = render(<MarkdownText text={markdown} />)
  97. expect(container.querySelector('script')).toBeNull()
  98. expect(container.querySelector('img')).toBeNull()
  99. const neutralized = [...container.querySelectorAll('p')]
  100. .find(paragraph => paragraph.textContent === 'script relative')
  101. expect(neutralized?.querySelector('a')).toBeNull()
  102. expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
  103. expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
  104. expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
  105. expect(screen.getByText('remote diagram')).toBeTruthy()
  106. })
  107. it('keeps incomplete streaming Markdown renderable', () => {
  108. const { container } = render(<MarkdownText text={'## Streaming\n\n- first\n- **unfinished'} />)
  109. expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()
  110. expect(container.querySelectorAll('li')).toHaveLength(2)
  111. expect(screen.getByText('**unfinished')).toBeTruthy()
  112. })
  113. })
  114. describe('JsonBlock', () => {
  115. it('collapsed by default; toggle reveals pretty-printed payload', () => {
  116. render(<JsonBlock label="args" payload={{ a: 1 }} />)
  117. expect(screen.queryByText(/"a": 1/)).toBeNull()
  118. fireEvent.click(screen.getByRole('button', { name: /args/ }))
  119. expect(screen.getByText(/"a": 1/)).toBeDefined()
  120. fireEvent.click(screen.getByRole('button', { name: /args/ }))
  121. expect(screen.queryByText(/"a": 1/)).toBeNull()
  122. })
  123. it('defaultOpen renders the body immediately', () => {
  124. const { container } = render(<JsonBlock label="args" payload={[1, 2]} defaultOpen />)
  125. expect(container.querySelector('pre')?.textContent).toContain('1')
  126. })
  127. it('stringifies undefined payloads via String()', () => {
  128. const { container } = render(<JsonBlock label="x" payload={undefined} defaultOpen />)
  129. expect(container.querySelector('pre')?.textContent).toBe('undefined')
  130. })
  131. it('falls back to String() for circular payloads', () => {
  132. const circular: { self?: unknown } = {}
  133. circular.self = circular
  134. const { container } = render(<JsonBlock label="x" payload={circular} defaultOpen />)
  135. expect(container.querySelector('pre')?.textContent).toBe('[object Object]')
  136. })
  137. it('truncates beyond the size cap with a suffix note', () => {
  138. const big = 'x'.repeat(30_000)
  139. const { container } = render(<JsonBlock label="x" payload={big} defaultOpen />)
  140. const body = container.querySelector('pre')!.textContent
  141. expect(body.length).toBeLessThan(30_000)
  142. expect(body).toContain('截断')
  143. })
  144. })