markdown.spec.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. // @vitest-environment jsdom
  2. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  3. import { afterEach, describe, expect, it } from 'vitest'
  4. import type { Extension } from 'micromark-util-types'
  5. import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
  6. import { remarkMathCompatibility } from '../src/markdown/remarkMathCompatibility.ts'
  7. afterEach(cleanup)
  8. describe('MessageText', () => {
  9. it('renders the text verbatim', () => {
  10. const { container } = render(<MessageText text={'# line1\n`line2`'} />)
  11. expect(container.textContent).toBe('# line1\n`line2`')
  12. expect(container.querySelector('h1')).toBeNull()
  13. })
  14. })
  15. describe('MarkdownText', () => {
  16. it('renders CommonMark and GFM elements as semantic DOM', () => {
  17. const markdown = [
  18. '# Heading',
  19. '',
  20. 'Paragraph with **strong**, *emphasis*, ~~deleted~~, `inline`, and [safe](https://example.com). ',
  21. 'Hard break.',
  22. '',
  23. '> Quote',
  24. '',
  25. '- parent',
  26. ' - child',
  27. '',
  28. '1. first',
  29. '2. second',
  30. '',
  31. '- [x] done',
  32. '- [ ] pending',
  33. '',
  34. '| Name | Value |',
  35. '| --- | --- |',
  36. '| alpha | beta |',
  37. '',
  38. '---',
  39. '',
  40. '```ts',
  41. 'const answer = 42',
  42. '```',
  43. '',
  44. '<https://deepseek.com>',
  45. ].join('\n')
  46. const { container } = render(<MarkdownText text={markdown} />)
  47. expect(screen.getByRole('heading', { level: 1, name: 'Heading' })).toBeTruthy()
  48. expect(container.querySelector('strong')?.textContent).toBe('strong')
  49. expect(container.querySelector('em')?.textContent).toBe('emphasis')
  50. expect(container.querySelector('del')?.textContent).toBe('deleted')
  51. expect(container.querySelector('blockquote')?.textContent?.trim()).toBe('Quote')
  52. expect(container.querySelectorAll('ul')).toHaveLength(3)
  53. expect(container.querySelector('ol')).not.toBeNull()
  54. expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2)
  55. expect(container.querySelector('table')?.textContent).toContain('alphabeta')
  56. expect(container.querySelector('hr')).not.toBeNull()
  57. expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
  58. // The ts fence routed through the shared CodeBlock: shiki token spans + banner.
  59. expect(container.querySelector('pre.shiki')).not.toBeNull()
  60. expect(screen.getByText('ts')).toBeTruthy()
  61. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  62. expect(container.querySelector('br')).not.toBeNull()
  63. expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
  64. expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
  65. })
  66. it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => {
  67. for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) {
  68. const { container, unmount } = render(<MarkdownText text={'```' + label + '\ncode body\n```'} />)
  69. expect(container.querySelector('pre.shiki')).toBeNull()
  70. expect(container.querySelector('pre code')?.textContent).toContain('code body')
  71. unmount()
  72. }
  73. })
  74. it('an empty fence keeps the stock pre; a language-less fence renders the plain CodeBlock arm', () => {
  75. const empty = render(<MarkdownText text={'```\n```'} />)
  76. expect(empty.container.querySelector('pre')?.outerHTML).toBe('<pre><code></code></pre>')
  77. const plain = render(<MarkdownText text={'```\nno language here\n```'} />)
  78. expect(plain.container.querySelector('pre.shiki')).toBeNull()
  79. expect(plain.container.querySelector('pre code')?.textContent).toContain('no language here')
  80. })
  81. it('streaming renders fences plain; the finalize swap highlights them', () => {
  82. const fence = '```ts\nconst answer = 42\n```'
  83. const live = render(<MarkdownText text={fence} streaming />)
  84. expect(live.container.querySelector('pre.shiki')).toBeNull()
  85. expect(live.container.querySelector('pre code')?.textContent).toContain('const answer = 42')
  86. live.unmount()
  87. const done = render(<MarkdownText text={fence} />)
  88. expect(done.container.querySelector('pre.shiki')).not.toBeNull()
  89. })
  90. it('forwards localized labels to fenced code blocks', () => {
  91. render(<MarkdownText text={'```ts\nconst answer = 42\n```'} codeLabels={{ copyLabel: 'Copy code', copiedLabel: 'Copied' }} />)
  92. expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
  93. })
  94. it('renders absolute HTTP(S) images with bounded presentation', () => {
  95. const markdown = [
  96. '![secure diagram](https://example.com/secure.png)',
  97. '![plain diagram](http://example.com/plain.png)',
  98. ].join('\n\n')
  99. const { container } = render(<MarkdownText text={markdown} />)
  100. const images = [...container.querySelectorAll('img')]
  101. expect(images.map(image => image.getAttribute('src'))).toEqual([
  102. 'https://example.com/secure.png',
  103. 'http://example.com/plain.png',
  104. ])
  105. for (const image of images) {
  106. expect(image.getAttribute('loading')).toBe('lazy')
  107. expect(image.getAttribute('decoding')).toBe('async')
  108. expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
  109. }
  110. })
  111. it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => {
  112. const markdown = [
  113. '<script>globalThis.compromised = true</script>',
  114. '<img src="x" onerror="globalThis.compromised = true">',
  115. '[script](javascript:alert(1)) [relative](/settings)',
  116. '[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
  117. '![relative diagram](private.png)',
  118. '![absolute diagram](/workspace/private.png)',
  119. '![file diagram](file:///workspace/private.png)',
  120. '![script diagram](javascript:alert(1))',
  121. '![mail diagram](mailto:dev@example.com)',
  122. ].join('\n\n')
  123. const { container } = render(<MarkdownText text={markdown} />)
  124. expect(container.querySelector('script')).toBeNull()
  125. expect(container.querySelector('img')).toBeNull()
  126. const neutralized = [...container.querySelectorAll('p')]
  127. .find(paragraph => paragraph.textContent === 'script relative')
  128. expect(neutralized?.querySelector('a')).toBeNull()
  129. expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
  130. expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
  131. expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
  132. expect(screen.getByText('relative diagram')).toBeTruthy()
  133. expect(screen.getByText('absolute diagram')).toBeTruthy()
  134. expect(screen.getByText('file diagram')).toBeTruthy()
  135. expect(screen.getByText('script diagram')).toBeTruthy()
  136. expect(screen.getByText('mail diagram')).toBeTruthy()
  137. })
  138. it('keeps incomplete streaming Markdown renderable', () => {
  139. const { container } = render(<MarkdownText text={'## Streaming\n\n- first\n- **unfinished'} />)
  140. expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()
  141. expect(container.querySelectorAll('li')).toHaveLength(2)
  142. expect(screen.getByText('**unfinished')).toBeTruthy()
  143. })
  144. it('renders inline and display TeX through KaTeX without enabling trusted commands', () => {
  145. const source = [
  146. 'Einstein wrote $E = mc^2$.',
  147. '',
  148. '$$',
  149. '\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p',
  150. '$$',
  151. '',
  152. '$\\href{javascript:alert(1)}{unsafe}$',
  153. ].join('\n')
  154. const { container } = render(<MarkdownText text={source} />)
  155. expect(container.querySelectorAll('.katex')).toHaveLength(3)
  156. expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
  157. expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\partial \\mathbf{u}}')
  158. expect(container.querySelector('a')).toBeNull()
  159. })
  160. it('renders common TeX delimiters and same-line tagged display blocks after the reply settles', () => {
  161. const source = [
  162. 'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
  163. '',
  164. '\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
  165. '',
  166. '$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
  167. '',
  168. '| Symbol | Value |',
  169. '| --- | --- |',
  170. '| $\\theta$ | \\(\\frac{1}{5}\\) |',
  171. ].join('\n')
  172. const { container } = render(<MarkdownText text={source} />)
  173. expect(container.querySelectorAll('.katex')).toHaveLength(6)
  174. expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
  175. expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\pi}{4}')
  176. expect([...container.querySelectorAll('.katex-display')].at(-1)?.querySelector('annotation')?.textContent)
  177. .toContain('\\tag{1}')
  178. expect(container.querySelector('.katex-error')).toBeNull()
  179. expect(container.querySelector('table .katex')).not.toBeNull()
  180. })
  181. it('keeps backslash delimiters correct across Markdown boundaries and malformed candidates', () => {
  182. const cases = [
  183. {
  184. source: '\\(\\alpha \\, \\beta\\)',
  185. math: 1,
  186. display: 0,
  187. },
  188. {
  189. source: String.raw`\\\(x\)`,
  190. math: 1,
  191. display: 0,
  192. value: 'x',
  193. },
  194. {
  195. source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)',
  196. math: 1,
  197. display: 0,
  198. value: '\\frac{1}{5}\n+\\frac{1}{7}',
  199. },
  200. {
  201. source: '\\[a\\\\\nb\\]',
  202. math: 1,
  203. display: 1,
  204. value: 'a\\\\\nb',
  205. },
  206. {
  207. source: '> \\[\n> \\frac{1}{5}\n> \\]',
  208. math: 1,
  209. display: 1,
  210. },
  211. {
  212. source: '- \\[\n \\frac{1}{5}\n \\]',
  213. math: 1,
  214. display: 1,
  215. },
  216. ]
  217. for (const item of cases) {
  218. const rendered = render(<MarkdownText text={item.source} />)
  219. expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
  220. expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
  221. expect(rendered.container.querySelector('.katex-error')).toBeNull()
  222. if ('value' in item) {
  223. expect(rendered.container.querySelector('annotation')?.textContent).toBe(item.value)
  224. }
  225. rendered.unmount()
  226. }
  227. const literal = render(<MarkdownText text={'\\\\(x\\)\n\n\\[x'} />)
  228. expect(literal.container.querySelectorAll('.katex')).toHaveLength(0)
  229. expect(literal.container.querySelector('.katex-display')).toBeNull()
  230. expect(literal.container.textContent).toContain('[x')
  231. })
  232. it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => {
  233. const cases = [
  234. { source: '$$\n\\theta\n$$', math: 1, display: 1 },
  235. { source: '$$$\\theta$$$', math: 1, display: 0 },
  236. { source: '$$a$b\nc', math: 0, display: 0 },
  237. { source: ' \\[\n \\theta\n \\]', math: 1, display: 1 },
  238. { source: '\\(\\theta', math: 0, display: 0 },
  239. { source: String.raw`\(a\\)`, math: 0, display: 0 },
  240. { source: '\\[\n\\[', math: 0, display: 0 },
  241. { source: '> \\[\nnot a quoted continuation\n\\]', math: 0, display: 0 },
  242. ]
  243. for (const item of cases) {
  244. const rendered = render(<MarkdownText text={item.source} />)
  245. expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
  246. expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
  247. expect(rendered.container.querySelector('.katex-error')).toBeNull()
  248. rendered.unmount()
  249. }
  250. })
  251. it('lets display math interrupt an open paragraph', () => {
  252. for (const source of ['Prose line\n\\[x\\]', 'Prose line\n$$x$$']) {
  253. const rendered = render(<MarkdownText text={source} />)
  254. expect(rendered.container.querySelectorAll('p')).toHaveLength(1)
  255. expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
  256. rendered.unmount()
  257. }
  258. })
  259. it('leaves a dollar block with trailing text to upstream inline math', () => {
  260. const { container } = render(<MarkdownText text="$$x$$ trailing" />)
  261. expect(container.querySelectorAll('.katex')).toHaveLength(1)
  262. expect(container.querySelector('.katex-display')).toBeNull()
  263. expect(container.querySelector('annotation')?.textContent).toBe('x')
  264. expect(container.textContent).toContain('trailing')
  265. })
  266. it('renders escaped dollars and even backslash pairs before closing fences', () => {
  267. const source = [
  268. String.raw`$$100\$$$`,
  269. '',
  270. String.raw`\(a\\\)`,
  271. '',
  272. String.raw`\[b\\\]`,
  273. ].join('\n')
  274. const { container } = render(<MarkdownText text={source} />)
  275. const values = [...container.querySelectorAll('annotation')].map(node => node.textContent)
  276. expect(values).toEqual([String.raw`100\$`, String.raw`a\\`, String.raw`b\\`])
  277. expect(container.querySelector('.katex-error')).toBeNull()
  278. })
  279. it('bounds fallback work for repeated unclosed backslash delimiters', () => {
  280. const startedAt = performance.now()
  281. const { container } = render(<MarkdownText text={'\\(x '.repeat(6_400)} />)
  282. expect(performance.now() - startedAt).toBeLessThan(1_000)
  283. expect(container.querySelector('.katex')).toBeNull()
  284. })
  285. it('leaves TeX-looking fenced code literal', () => {
  286. const source = '```tex\n\\[\\frac{1}{5}\\]\n$$x \\tag{1}$$\n```'
  287. const { container } = render(<MarkdownText text={source} />)
  288. expect(container.querySelector('.katex')).toBeNull()
  289. expect(container.querySelector('pre code')?.textContent).toContain('\\[\\frac{1}{5}\\]')
  290. expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$')
  291. })
  292. it('registers the compatibility extension on a bare remark processor', () => {
  293. const data: { micromarkExtensions?: Extension[] } = {}
  294. remarkMathCompatibility.call({ data: () => data })
  295. expect(data.micromarkExtensions).toHaveLength(1)
  296. })
  297. it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {
  298. const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial'
  299. const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$'
  300. const live = render(<MarkdownText text={partial} streaming />)
  301. expect(live.container.querySelector('.katex')).toBeNull()
  302. expect(live.container.querySelector('.katex-error')).toBeNull()
  303. expect(live.container.textContent).toContain('\\frac{\\partial \\mathbf{u}}{\\partial')
  304. live.rerender(<MarkdownText text={complete} />)
  305. expect(live.container.querySelectorAll('.katex')).toHaveLength(1)
  306. expect(live.container.querySelectorAll('.katex-display')).toHaveLength(1)
  307. expect(live.container.querySelector('.katex-error')).toBeNull()
  308. })
  309. })
  310. describe('JsonBlock', () => {
  311. it('collapsed by default; toggle reveals pretty-printed payload', () => {
  312. render(<JsonBlock label="args" payload={{ a: 1 }} />)
  313. expect(screen.queryByText(/"a": 1/)).toBeNull()
  314. fireEvent.click(screen.getByRole('button', { name: /args/ }))
  315. expect(screen.getByText(/"a": 1/)).toBeDefined()
  316. fireEvent.click(screen.getByRole('button', { name: /args/ }))
  317. expect(screen.queryByText(/"a": 1/)).toBeNull()
  318. })
  319. it('defaultOpen renders the body immediately', () => {
  320. const { container } = render(<JsonBlock label="args" payload={[1, 2]} defaultOpen />)
  321. expect(container.querySelector('pre')?.textContent).toContain('1')
  322. })
  323. it('stringifies undefined payloads via String()', () => {
  324. const { container } = render(<JsonBlock label="x" payload={undefined} defaultOpen />)
  325. expect(container.querySelector('pre')?.textContent).toBe('undefined')
  326. })
  327. it('falls back to String() for circular payloads', () => {
  328. const circular: { self?: unknown } = {}
  329. circular.self = circular
  330. const { container } = render(<JsonBlock label="x" payload={circular} defaultOpen />)
  331. expect(container.querySelector('pre')?.textContent).toBe('[object Object]')
  332. })
  333. it('truncates beyond the size cap with a suffix note', () => {
  334. const big = 'x'.repeat(30_000)
  335. const { container } = render(<JsonBlock label="x" payload={big} defaultOpen />)
  336. const body = container.querySelector('pre')!.textContent
  337. expect(body.length).toBeLessThan(30_000)
  338. expect(body).toContain('截断')
  339. })
  340. })