markdown.client.spec.tsx 22 KB

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