markdown.spec.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  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. expect(container.querySelector('br')).not.toBeNull()
  57. expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
  58. expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
  59. })
  60. it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
  61. const markdown = [
  62. '<script>globalThis.compromised = true</script>',
  63. '<img src="x" onerror="globalThis.compromised = true">',
  64. '[script](javascript:alert(1)) [relative](/settings)',
  65. '[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
  66. '![remote diagram](https://example.com/private.png)',
  67. ].join('\n\n')
  68. const { container } = render(<MarkdownText text={markdown} />)
  69. expect(container.querySelector('script')).toBeNull()
  70. expect(container.querySelector('img')).toBeNull()
  71. const neutralized = [...container.querySelectorAll('p')]
  72. .find(paragraph => paragraph.textContent === 'script relative')
  73. expect(neutralized?.querySelector('a')).toBeNull()
  74. expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
  75. expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
  76. expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
  77. expect(screen.getByText('remote diagram')).toBeTruthy()
  78. })
  79. it('keeps incomplete streaming Markdown renderable', () => {
  80. const { container } = render(<MarkdownText text={'## Streaming\n\n- first\n- **unfinished'} />)
  81. expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()
  82. expect(container.querySelectorAll('li')).toHaveLength(2)
  83. expect(screen.getByText('**unfinished')).toBeTruthy()
  84. })
  85. })
  86. describe('JsonBlock', () => {
  87. it('collapsed by default; toggle reveals pretty-printed payload', () => {
  88. render(<JsonBlock label="args" payload={{ a: 1 }} />)
  89. expect(screen.queryByText(/"a": 1/)).toBeNull()
  90. fireEvent.click(screen.getByRole('button', { name: /args/ }))
  91. expect(screen.getByText(/"a": 1/)).toBeDefined()
  92. fireEvent.click(screen.getByRole('button', { name: /args/ }))
  93. expect(screen.queryByText(/"a": 1/)).toBeNull()
  94. })
  95. it('defaultOpen renders the body immediately', () => {
  96. const { container } = render(<JsonBlock label="args" payload={[1, 2]} defaultOpen />)
  97. expect(container.querySelector('pre')?.textContent).toContain('1')
  98. })
  99. it('stringifies undefined payloads via String()', () => {
  100. const { container } = render(<JsonBlock label="x" payload={undefined} defaultOpen />)
  101. expect(container.querySelector('pre')?.textContent).toBe('undefined')
  102. })
  103. it('falls back to String() for circular payloads', () => {
  104. const circular: { self?: unknown } = {}
  105. circular.self = circular
  106. const { container } = render(<JsonBlock label="x" payload={circular} defaultOpen />)
  107. expect(container.querySelector('pre')?.textContent).toBe('[object Object]')
  108. })
  109. it('truncates beyond the size cap with a suffix note', () => {
  110. const big = 'x'.repeat(30_000)
  111. const { container } = render(<JsonBlock label="x" payload={big} defaultOpen />)
  112. const body = container.querySelector('pre')!.textContent!
  113. expect(body.length).toBeLessThan(30_000)
  114. expect(body).toContain('截断')
  115. })
  116. })