markdown.client.spec.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. // @vitest-environment jsdom
  2. import { cleanup, fireEvent, render, screen } from '@testing-library/react'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { JsonBlock, MarkdownText } from './markdown-test-components.tsx'
  5. import { LinkIcon, MarkdownDelegateProvider } from '../src/index.ts'
  6. import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts'
  7. import { mathCompatibility } from '../src/markdown/mathCompatibility.ts'
  8. afterEach(cleanup)
  9. describe('MarkdownText', () => {
  10. it('renders CommonMark and GFM elements as semantic DOM', () => {
  11. const markdown = [
  12. '# Heading',
  13. '',
  14. 'Paragraph with **strong**, *emphasis*, ~~deleted~~, `inline`, and [safe](https://example.com). ',
  15. 'Hard break.',
  16. '',
  17. '> Quote',
  18. '',
  19. '- parent',
  20. ' - child',
  21. '',
  22. '1. first',
  23. '2. second',
  24. '',
  25. '- [x] done',
  26. '- [ ] pending',
  27. '',
  28. '| Name | Value |',
  29. '| --- | --- |',
  30. '| alpha | beta |',
  31. '',
  32. '---',
  33. '',
  34. '```ts',
  35. 'const answer = 42',
  36. '```',
  37. '',
  38. '<https://deepseek.com>',
  39. ].join('\n')
  40. const { container } = render(<MarkdownText text={markdown} />)
  41. expect(screen.getByRole('heading', { level: 1, name: 'Heading' })).toBeTruthy()
  42. expect(container.querySelector('strong')?.textContent).toBe('strong')
  43. expect(container.querySelector('em')?.textContent).toBe('emphasis')
  44. expect(container.querySelector('del')?.textContent).toBe('deleted')
  45. expect(container.querySelector('blockquote')?.textContent?.trim()).toBe('Quote')
  46. expect(container.querySelectorAll('ul')).toHaveLength(3)
  47. expect(container.querySelector('ol')).not.toBeNull()
  48. expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2)
  49. expect(container.querySelector('table')?.textContent).toContain('alphabeta')
  50. expect(container.querySelector('hr')).not.toBeNull()
  51. expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
  52. // The ts fence routed through the shared CodeBlock: shiki token spans + banner.
  53. expect(container.querySelector('pre.shiki')).not.toBeNull()
  54. expect(screen.getByText('ts')).toBeTruthy()
  55. expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
  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('closes punctuation-terminated strong emphasis before adjacent CJK text', () => {
  61. const cases = [
  62. ['**注意:**内容', '注意:'],
  63. ['**Notice:**内容', 'Notice:'],
  64. ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'],
  65. ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'],
  66. ['**句号。**后续', '句号。'],
  67. ['**Period.**后续', 'Period.'],
  68. ['**提醒!**继续', '提醒!'],
  69. ['**Warning!**继续', 'Warning!'],
  70. ] as const
  71. const source = cases.map(([markdown]) => markdown).join('\n\n')
  72. for (const streaming of [false, true]) {
  73. const rendered = render(<MarkdownText text={source} streaming={streaming} />)
  74. expect([...rendered.container.querySelectorAll('strong')].map(node => node.textContent))
  75. .toEqual(cases.map(([, strong]) => strong))
  76. rendered.unmount()
  77. }
  78. })
  79. it('keeps the CJK strong extension out of escaped, code, math, and ASCII contexts', () => {
  80. const source = [
  81. String.raw`\**注意:**内容`,
  82. '`**注意:**内容`',
  83. '**Notice:**text',
  84. '*提醒!*继续',
  85. '$**注意:**内容$',
  86. '```md',
  87. '**注意:**内容',
  88. '```',
  89. '**普通**内容',
  90. '*普通*内容',
  91. ].join('\n\n')
  92. const { container } = render(<MarkdownText text={source} />)
  93. expect([...container.querySelectorAll('strong')].map(node => node.textContent)).toEqual(['普通'])
  94. expect([...container.querySelectorAll('em')].map(node => node.textContent)).toEqual(['普通'])
  95. expect(container.querySelector('code')?.textContent).toBe('**注意:**内容')
  96. expect(container.querySelector('.katex annotation')?.textContent).toBe('**注意:**内容')
  97. expect(container.querySelector('pre code')?.textContent).toContain('**注意:**内容')
  98. expect(container.textContent).toContain('**Notice:**text')
  99. expect(container.textContent).toContain('*提醒!*继续')
  100. expect(container.textContent).toContain('**注意:**内容')
  101. })
  102. it('links complete HTTP(S) inline code without promoting commands, unsafe schemes, or fences', () => {
  103. const localUrl = 'http://127.0.0.1:3199/?demo=1'
  104. const remoteUrl = 'https://example.com/preview?q=one%20two#result'
  105. const source = [
  106. `\`${localUrl}\``,
  107. `\`${remoteUrl}\``,
  108. '`curl http://127.0.0.1:3199/?demo=1`',
  109. '`javascript:alert(1)`',
  110. '`mailto:dev@example.com`',
  111. `\` ${localUrl} \``,
  112. '```',
  113. localUrl,
  114. '```',
  115. ].join('\n\n')
  116. const { container } = render(<MarkdownText text={source} />)
  117. const links = screen.getAllByRole('link')
  118. expect(links.map(link => link.getAttribute('href'))).toEqual([localUrl, remoteUrl])
  119. for (const link of links) {
  120. expect(link.closest('code')).not.toBeNull()
  121. expect(link.getAttribute('target')).toBe('_blank')
  122. expect(link.getAttribute('rel')).toBe('noopener noreferrer')
  123. }
  124. links[0]?.focus()
  125. expect(document.activeElement).toBe(links[0])
  126. expect(screen.getByText('curl http://127.0.0.1:3199/?demo=1').closest('a')).toBeNull()
  127. expect(screen.getByText('javascript:alert(1)').closest('a')).toBeNull()
  128. expect(screen.getByText('mailto:dev@example.com').closest('a')).toBeNull()
  129. const paddedCode = [...container.querySelectorAll('code')]
  130. .find(code => code.textContent === ` ${localUrl} `)
  131. expect(paddedCode?.querySelector('a')).toBeNull()
  132. expect(container.querySelector('pre code a')).toBeNull()
  133. })
  134. it('delegates ordinary HTTP(S) clicks while preserving modified-click behavior', () => {
  135. const openExternalLink = vi.fn<(href: string) => void>()
  136. render(
  137. <MarkdownDelegateProvider openExternalLink={openExternalLink}>
  138. <MarkdownText text={'[secure](https://example.com/a) [plain](http://example.com/b) `https://example.com/code` [mail](mailto:dev@example.com)'} />
  139. </MarkdownDelegateProvider>,
  140. )
  141. const secure = screen.getByRole('link', { name: 'secure' })
  142. const plain = screen.getByRole('link', { name: 'plain' })
  143. const code = screen.getByRole('link', { name: 'https://example.com/code' })
  144. expect(fireEvent.click(secure)).toBe(false)
  145. expect(fireEvent.click(plain)).toBe(false)
  146. expect(fireEvent.click(code)).toBe(false)
  147. expect(openExternalLink.mock.calls).toEqual([
  148. ['https://example.com/a'],
  149. ['http://example.com/b'],
  150. ['https://example.com/code'],
  151. ])
  152. for (const modified of [
  153. { button: 1 },
  154. { metaKey: true },
  155. { ctrlKey: true },
  156. { shiftKey: true },
  157. { altKey: true },
  158. ]) expect(fireEvent.click(secure, modified)).toBe(true)
  159. expect(openExternalLink).toHaveBeenCalledTimes(3)
  160. expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
  161. })
  162. it('updates the delegated link handler while Markdown is streaming', () => {
  163. const first = vi.fn<(href: string) => void>()
  164. const second = vi.fn<(href: string) => void>()
  165. const source = '[web](https://example.com/)'
  166. const view = render(
  167. <MarkdownDelegateProvider openExternalLink={first}>
  168. <MarkdownText text={source} streaming />
  169. </MarkdownDelegateProvider>,
  170. )
  171. fireEvent.click(screen.getByRole('link', { name: 'web' }))
  172. view.rerender(
  173. <MarkdownDelegateProvider openExternalLink={second}>
  174. <MarkdownText text={source} streaming />
  175. </MarkdownDelegateProvider>,
  176. )
  177. fireEvent.click(screen.getByRole('link', { name: 'web' }))
  178. expect(first).toHaveBeenCalledOnce()
  179. expect(second).toHaveBeenCalledOnce()
  180. })
  181. it('links inline code through the file-mention resolver: URL first, settled only, never inside links', () => {
  182. const opened: string[] = []
  183. const fileMentions = {
  184. resolve: (value: string) => value === 'index.html' || value === 'out/index.html'
  185. ? { open: () => { opened.push(value) }, label: 'Open out/index.html', title: 'out/index.html' }
  186. : undefined,
  187. }
  188. const source = [
  189. '`index.html`',
  190. '`other.css`',
  191. '`https://example.com/`',
  192. // Inside an anchor the mention stays inert code: a button cannot nest there.
  193. '[see `out/index.html`](https://example.com/doc)',
  194. '[ref `out/index.html`][target]',
  195. '[target]: https://example.com/ref',
  196. '```',
  197. 'index.html',
  198. '```',
  199. ].join('\n\n')
  200. const { container } = render(<MarkdownText text={source} fileMentions={fileMentions} />)
  201. const mention = screen.getByRole('button', { name: 'Open out/index.html' })
  202. expect(mention.closest('code')).not.toBeNull()
  203. // The full path rides title, the same disambiguator the row's chips carry.
  204. expect(mention.getAttribute('title')).toBe('out/index.html')
  205. fireEvent.click(mention)
  206. expect(opened).toEqual(['index.html'])
  207. // Exactly one live mention: the two inside anchors declined, and an
  208. // unresolved token plus fenced code stay inert.
  209. expect(container.querySelectorAll('code button')).toHaveLength(1)
  210. expect(container.querySelectorAll('a code button, a button')).toHaveLength(0)
  211. expect(screen.getByText('other.css').closest('button')).toBeNull()
  212. // URL promotion wins before the resolver sees a token.
  213. expect(screen.getByText('https://example.com/').closest('a')).not.toBeNull()
  214. // Streaming renders keep mentions off — the one gate lives here: cached
  215. // frozen elements must not bake in handlers that could go stale.
  216. const streamed = render(
  217. <MarkdownText text={'`index.html`\n\nmore\n\n'} streaming fileMentions={fileMentions} />,
  218. )
  219. expect(streamed.container.querySelector('button')).toBeNull()
  220. })
  221. it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => {
  222. const extension = cjkFriendlyStrong()
  223. expect(cjkFriendlyStrong()).toBe(extension)
  224. const construct = extension.text?.[42]
  225. const tokenizer = Array.isArray(construct) ? construct[0]?.tokenize : construct?.tokenize
  226. expect(tokenizer).toBeTypeOf('function')
  227. expect(() => tokenizer?.call({
  228. parser: { constructs: { attentionMarkers: {} } },
  229. previous: null,
  230. } as never, {} as never, () => undefined, () => undefined)).toThrow(
  231. 'micromark CommonMark attention markers are unavailable',
  232. )
  233. })
  234. it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => {
  235. for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) {
  236. const { container, unmount } = render(<MarkdownText text={'```' + label + '\ncode body\n```'} />)
  237. expect(container.querySelector('pre.shiki')).toBeNull()
  238. expect(container.querySelector('pre code')?.textContent).toContain('code body')
  239. unmount()
  240. }
  241. })
  242. it('an empty fence keeps the stock pre; a language-less fence renders the plain CodeBlock arm', () => {
  243. const empty = render(<MarkdownText text={'```\n```'} />)
  244. expect(empty.container.querySelector('pre')?.outerHTML).toBe('<pre><code></code></pre>')
  245. const plain = render(<MarkdownText text={'```\nno language here\n```'} />)
  246. expect(plain.container.querySelector('pre.shiki')).toBeNull()
  247. expect(plain.container.querySelector('pre code')?.textContent).toContain('no language here')
  248. })
  249. it('streaming highlights a registered-grammar fence; the finalize swap keeps it highlighted', () => {
  250. const fence = '```ts\nconst answer = 42\n```'
  251. const live = render(<MarkdownText text={fence} streaming />)
  252. const pre = live.container.querySelector('pre.shiki')
  253. expect(pre).not.toBeNull()
  254. expect(pre?.textContent).toContain('const answer = 42')
  255. expect(pre?.querySelectorAll('span[style]').length).toBeGreaterThan(1)
  256. live.rerender(<MarkdownText text={fence} />)
  257. expect(live.container.querySelector('pre.shiki')).not.toBeNull()
  258. })
  259. it('a growing unclosed fence extends highlighting; completed lines keep their DOM nodes', () => {
  260. const live = render(<MarkdownText text={'```ts\nconst a = 1\nlet partial'} streaming />)
  261. const lines = live.container.querySelectorAll('pre.shiki .line')
  262. expect(lines).toHaveLength(2)
  263. const firstLine = lines[0]
  264. live.rerender(<MarkdownText text={'```ts\nconst a = 1\nlet partial = 2\n// tail\n```'} streaming />)
  265. const grown = live.container.querySelectorAll('pre.shiki .line')
  266. expect(grown).toHaveLength(3)
  267. expect(grown[0]).toBe(firstLine)
  268. expect(grown[2]?.textContent).toBe('// tail')
  269. expect(live.container.querySelector('pre.shiki')?.textContent).toBe('const a = 1\nlet partial = 2\n// tail')
  270. })
  271. it('a fence whose info string is still mid-chunk has no content to color, so no wrong grammar ever paints', () => {
  272. // '```t' could complete to ts, toml, … — but until its line ends, the
  273. // fence has no content and keeps the stock empty pre.
  274. const live = render(<MarkdownText text={'```t'} streaming />)
  275. expect(live.container.querySelector('pre')?.outerHTML).toBe('<pre><code class="language-t"></code></pre>')
  276. live.rerender(<MarkdownText text={'```ts\nconst answer = 42'} streaming />)
  277. expect(live.container.querySelector('pre.shiki')?.textContent).toBe('const answer = 42')
  278. })
  279. it('streaming keeps unknown and language-less fences on the plain arm, and ```math literal until settle', () => {
  280. const live = render(
  281. <MarkdownText text={'```cobol\nDISPLAY "X".\n```\n\n```\nno language\n```\n\n```math\n\\sqrt{2}\n```'} streaming />,
  282. )
  283. expect(live.container.querySelector('pre.shiki')).toBeNull()
  284. expect(live.container.querySelector('.katex')).toBeNull()
  285. expect(live.container.textContent).toContain('\\sqrt{2}')
  286. })
  287. it('forwards localized labels to fenced code blocks', () => {
  288. render(<MarkdownText text={'```ts\nconst answer = 42\n```'} codeLabels={{ copyLabel: 'Copy code', copiedLabel: 'Copied' }} />)
  289. expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
  290. })
  291. it('renders absolute HTTP(S) images with bounded presentation', () => {
  292. const markdown = [
  293. '![secure diagram](https://example.com/secure.png)',
  294. '![plain diagram](http://example.com/plain.png)',
  295. ].join('\n\n')
  296. const { container } = render(<MarkdownText text={markdown} />)
  297. const images = [...container.querySelectorAll('img')]
  298. expect(images.map(image => image.getAttribute('src'))).toEqual([
  299. 'https://example.com/secure.png',
  300. 'http://example.com/plain.png',
  301. ])
  302. for (const image of images) {
  303. expect(image.getAttribute('loading')).toBe('lazy')
  304. expect(image.getAttribute('decoding')).toBe('async')
  305. expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
  306. }
  307. })
  308. it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => {
  309. const markdown = [
  310. '<script>globalThis.compromised = true</script>',
  311. '<img src="x" onerror="globalThis.compromised = true">',
  312. '[script](javascript:alert(1)) [relative](/settings)',
  313. '[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
  314. '![relative diagram](private.png)',
  315. '![absolute diagram](/workspace/private.png)',
  316. '![file diagram](file:///workspace/private.png)',
  317. '![script diagram](javascript:alert(1))',
  318. '![mail diagram](mailto:dev@example.com)',
  319. ].join('\n\n')
  320. const { container } = render(<MarkdownText text={markdown} />)
  321. expect(container.querySelector('script')).toBeNull()
  322. expect(container.querySelector('img')).toBeNull()
  323. const neutralized = [...container.querySelectorAll('p')]
  324. .find(paragraph => paragraph.textContent === 'script relative')
  325. expect(neutralized?.querySelector('a')).toBeNull()
  326. expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
  327. expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
  328. expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
  329. expect(screen.getByText('relative diagram')).toBeTruthy()
  330. expect(screen.getByText('absolute diagram')).toBeTruthy()
  331. expect(screen.getByText('file diagram')).toBeTruthy()
  332. expect(screen.getByText('script diagram')).toBeTruthy()
  333. expect(screen.getByText('mail diagram')).toBeTruthy()
  334. })
  335. it('leads a known site link with its own mark and an unknown host with the globe', () => {
  336. const { container } = render(<MarkdownText text={'[repo](https://github.com/org/repo) [docs](https://example.com/a)'} />)
  337. const marks = [...container.querySelectorAll('p a svg path')].map(path => path.getAttribute('d'))
  338. const globe = render(<LinkIcon kind="url" />).container.querySelector('path')!.getAttribute('d')
  339. const github = render(<LinkIcon kind="url" href="https://github.com/a" />).container.querySelector('path')!.getAttribute('d')
  340. expect(marks).toEqual([github, globe])
  341. })
  342. it('keeps incomplete streaming Markdown renderable', () => {
  343. const { container } = render(<MarkdownText text={'## Streaming\n\n- first\n- **unfinished'} />)
  344. expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()
  345. expect(container.querySelectorAll('li')).toHaveLength(2)
  346. expect(screen.getByText('**unfinished')).toBeTruthy()
  347. })
  348. it('renders inline and display TeX through KaTeX without enabling trusted commands', () => {
  349. const source = [
  350. 'Einstein wrote $E = mc^2$.',
  351. '',
  352. '$$',
  353. '\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p',
  354. '$$',
  355. '',
  356. '$\\href{javascript:alert(1)}{unsafe}$',
  357. ].join('\n')
  358. const { container } = render(<MarkdownText text={source} />)
  359. expect(container.querySelectorAll('.katex')).toHaveLength(3)
  360. expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
  361. expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\partial \\mathbf{u}}')
  362. expect(container.querySelector('a')).toBeNull()
  363. })
  364. it('renders common TeX delimiters and same-line tagged display blocks after the reply settles', () => {
  365. const source = [
  366. 'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).',
  367. '',
  368. '\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]',
  369. '',
  370. '$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$',
  371. '',
  372. '| Symbol | Value |',
  373. '| --- | --- |',
  374. '| $\\theta$ | \\(\\frac{1}{5}\\) |',
  375. ].join('\n')
  376. const { container } = render(<MarkdownText text={source} />)
  377. expect(container.querySelectorAll('.katex')).toHaveLength(6)
  378. expect(container.querySelectorAll('.katex-display')).toHaveLength(2)
  379. expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\pi}{4}')
  380. expect([...container.querySelectorAll('.katex-display')].at(-1)?.querySelector('annotation')?.textContent)
  381. .toContain('\\tag{1}')
  382. expect(container.querySelector('.katex-error')).toBeNull()
  383. expect(container.querySelector('table .katex')).not.toBeNull()
  384. })
  385. it('keeps backslash delimiters correct across Markdown boundaries and malformed candidates', () => {
  386. const cases = [
  387. {
  388. source: '\\(\\alpha \\, \\beta\\)',
  389. math: 1,
  390. display: 0,
  391. },
  392. {
  393. source: String.raw`\\\(x\)`,
  394. math: 1,
  395. display: 0,
  396. value: 'x',
  397. },
  398. {
  399. source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)',
  400. math: 1,
  401. display: 0,
  402. value: '\\frac{1}{5}\n+\\frac{1}{7}',
  403. },
  404. {
  405. source: '\\[a\\\\\nb\\]',
  406. math: 1,
  407. display: 1,
  408. value: 'a\\\\\nb',
  409. },
  410. {
  411. source: '> \\[\n> \\frac{1}{5}\n> \\]',
  412. math: 1,
  413. display: 1,
  414. },
  415. {
  416. source: '- \\[\n \\frac{1}{5}\n \\]',
  417. math: 1,
  418. display: 1,
  419. },
  420. ]
  421. for (const item of cases) {
  422. const rendered = render(<MarkdownText text={item.source} />)
  423. expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
  424. expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
  425. expect(rendered.container.querySelector('.katex-error')).toBeNull()
  426. if ('value' in item) {
  427. expect(rendered.container.querySelector('annotation')?.textContent).toBe(item.value)
  428. }
  429. rendered.unmount()
  430. }
  431. const literal = render(<MarkdownText text={'\\\\(x\\)\n\n\\[x'} />)
  432. expect(literal.container.querySelectorAll('.katex')).toHaveLength(0)
  433. expect(literal.container.querySelector('.katex-display')).toBeNull()
  434. expect(literal.container.textContent).toContain('[x')
  435. })
  436. it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => {
  437. const cases = [
  438. { source: '$$\n\\theta\n$$', math: 1, display: 1 },
  439. { source: '$$$\\theta$$$', math: 1, display: 0 },
  440. { source: '$$a$b\nc', math: 0, display: 0 },
  441. { source: ' \\[\n \\theta\n \\]', math: 1, display: 1 },
  442. { source: '\\(\\theta', math: 0, display: 0 },
  443. { source: String.raw`\(a\\)`, math: 0, display: 0 },
  444. { source: '\\[\n\\[', math: 0, display: 0 },
  445. { source: '> \\[\nnot a quoted continuation\n\\]', math: 0, display: 0 },
  446. ]
  447. for (const item of cases) {
  448. const rendered = render(<MarkdownText text={item.source} />)
  449. expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math)
  450. expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display)
  451. expect(rendered.container.querySelector('.katex-error')).toBeNull()
  452. rendered.unmount()
  453. }
  454. })
  455. it('lets display math interrupt an open paragraph', () => {
  456. for (const source of ['Prose line\n\\[x\\]', 'Prose line\n$$x$$']) {
  457. const rendered = render(<MarkdownText text={source} />)
  458. expect(rendered.container.querySelectorAll('p')).toHaveLength(1)
  459. expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1)
  460. rendered.unmount()
  461. }
  462. })
  463. it('leaves a dollar block with trailing text to upstream inline math', () => {
  464. const { container } = render(<MarkdownText text="$$x$$ trailing" />)
  465. expect(container.querySelectorAll('.katex')).toHaveLength(1)
  466. expect(container.querySelector('.katex-display')).toBeNull()
  467. expect(container.querySelector('annotation')?.textContent).toBe('x')
  468. expect(container.textContent).toContain('trailing')
  469. })
  470. it('renders escaped dollars and even backslash pairs before closing fences', () => {
  471. const source = [
  472. String.raw`$$100\$$$`,
  473. '',
  474. String.raw`\(a\\\)`,
  475. '',
  476. String.raw`\[b\\\]`,
  477. ].join('\n')
  478. const { container } = render(<MarkdownText text={source} />)
  479. const values = [...container.querySelectorAll('annotation')].map(node => node.textContent)
  480. expect(values).toEqual([String.raw`100\$`, String.raw`a\\`, String.raw`b\\`])
  481. expect(container.querySelector('.katex-error')).toBeNull()
  482. })
  483. it('bounds fallback work for repeated unclosed backslash delimiters', () => {
  484. const startedAt = performance.now()
  485. const { container } = render(<MarkdownText text={'\\(x '.repeat(6_400)} />)
  486. expect(performance.now() - startedAt).toBeLessThan(3_000)
  487. expect(container.querySelector('.katex')).toBeNull()
  488. })
  489. it('leaves TeX-looking fenced code literal', () => {
  490. const source = '```tex\n\\[\\frac{1}{5}\\]\n$$x \\tag{1}$$\n```'
  491. const { container } = render(<MarkdownText text={source} />)
  492. expect(container.querySelector('.katex')).toBeNull()
  493. expect(container.querySelector('pre code')?.textContent).toContain('\\[\\frac{1}{5}\\]')
  494. expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$')
  495. })
  496. it('exposes the compatibility syntax as a micromark extension', () => {
  497. const extension = mathCompatibility()
  498. expect(Object.keys(extension)).toEqual(['flow', 'text'])
  499. expect(mathCompatibility()).toBe(extension)
  500. })
  501. it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {
  502. const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial'
  503. const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$'
  504. const live = render(<MarkdownText text={partial} streaming />)
  505. expect(live.container.querySelector('.katex')).toBeNull()
  506. expect(live.container.querySelector('.katex-error')).toBeNull()
  507. expect(live.container.textContent).toContain('\\frac{\\partial \\mathbf{u}}{\\partial')
  508. live.rerender(<MarkdownText text={complete} />)
  509. expect(live.container.querySelectorAll('.katex')).toHaveLength(1)
  510. expect(live.container.querySelectorAll('.katex-display')).toHaveLength(1)
  511. expect(live.container.querySelector('.katex-error')).toBeNull()
  512. })
  513. })
  514. describe('JsonBlock', () => {
  515. it('collapsed by default; toggle reveals pretty-printed payload', () => {
  516. render(<JsonBlock label="args" payload={{ a: 1 }} />)
  517. expect(screen.queryByText(/"a": 1/)).toBeNull()
  518. fireEvent.click(screen.getByRole('button', { name: /args/ }))
  519. expect(screen.getByText(/"a": 1/)).toBeDefined()
  520. fireEvent.click(screen.getByRole('button', { name: /args/ }))
  521. expect(screen.queryByText(/"a": 1/)).toBeNull()
  522. })
  523. it('defaultOpen renders the body immediately', () => {
  524. const { container } = render(<JsonBlock label="args" payload={[1, 2]} defaultOpen />)
  525. expect(container.querySelector('pre')?.textContent).toContain('1')
  526. })
  527. it('stringifies undefined payloads via String()', () => {
  528. const { container } = render(<JsonBlock label="x" payload={undefined} defaultOpen />)
  529. expect(container.querySelector('pre')?.textContent).toBe('undefined')
  530. })
  531. it('falls back to String() for circular payloads', () => {
  532. const circular: { self?: unknown } = {}
  533. circular.self = circular
  534. const { container } = render(<JsonBlock label="x" payload={circular} defaultOpen />)
  535. expect(container.querySelector('pre')?.textContent).toBe('[object Object]')
  536. })
  537. it('truncates beyond the size cap with a suffix note', () => {
  538. const big = 'x'.repeat(30_000)
  539. const { container } = render(<JsonBlock label="x" payload={big} defaultOpen />)
  540. const body = container.querySelector('pre')!.textContent
  541. expect(body.length).toBeLessThan(30_000)
  542. expect(body).toContain('截断')
  543. })
  544. })