// @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts' import { mathCompatibility } from '../src/markdown/mathCompatibility.ts' afterEach(cleanup) describe('MessageText', () => { it('renders the text verbatim', () => { const { container } = render() expect(container.textContent).toBe('# line1\n`line2`') expect(container.querySelector('h1')).toBeNull() }) }) describe('MarkdownText', () => { it('renders CommonMark and GFM elements as semantic DOM', () => { const markdown = [ '# Heading', '', 'Paragraph with **strong**, *emphasis*, ~~deleted~~, `inline`, and [safe](https://example.com). ', 'Hard break.', '', '> Quote', '', '- parent', ' - child', '', '1. first', '2. second', '', '- [x] done', '- [ ] pending', '', '| Name | Value |', '| --- | --- |', '| alpha | beta |', '', '---', '', '```ts', 'const answer = 42', '```', '', '', ].join('\n') const { container } = render() expect(screen.getByRole('heading', { level: 1, name: 'Heading' })).toBeTruthy() expect(container.querySelector('strong')?.textContent).toBe('strong') expect(container.querySelector('em')?.textContent).toBe('emphasis') expect(container.querySelector('del')?.textContent).toBe('deleted') expect(container.querySelector('blockquote')?.textContent?.trim()).toBe('Quote') expect(container.querySelectorAll('ul')).toHaveLength(3) expect(container.querySelector('ol')).not.toBeNull() expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2) expect(container.querySelector('table')?.textContent).toContain('alphabeta') expect(container.querySelector('hr')).not.toBeNull() expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42') // The ts fence routed through the shared CodeBlock: shiki token spans + banner. expect(container.querySelector('pre.shiki')).not.toBeNull() expect(screen.getByText('ts')).toBeTruthy() expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() expect(container.querySelector('br')).not.toBeNull() expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank') expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() }) it('closes punctuation-terminated strong emphasis before adjacent CJK text', () => { const cases = [ ['**注意:**内容', '注意:'], ['**Notice:**内容', 'Notice:'], ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'], ['**事件中间件(waterfall)**实现', '事件中间件(waterfall)'], ['**句号。**后续', '句号。'], ['**Period.**后续', 'Period.'], ['**提醒!**继续', '提醒!'], ['**Warning!**继续', 'Warning!'], ] as const const source = cases.map(([markdown]) => markdown).join('\n\n') for (const streaming of [false, true]) { const rendered = render() expect([...rendered.container.querySelectorAll('strong')].map(node => node.textContent)) .toEqual(cases.map(([, strong]) => strong)) rendered.unmount() } }) it('keeps the CJK strong extension out of escaped, code, math, and ASCII contexts', () => { const source = [ String.raw`\**注意:**内容`, '`**注意:**内容`', '**Notice:**text', '*提醒!*继续', '$**注意:**内容$', '```md', '**注意:**内容', '```', '**普通**内容', '*普通*内容', ].join('\n\n') const { container } = render() expect([...container.querySelectorAll('strong')].map(node => node.textContent)).toEqual(['普通']) expect([...container.querySelectorAll('em')].map(node => node.textContent)).toEqual(['普通']) expect(container.querySelector('code')?.textContent).toBe('**注意:**内容') expect(container.querySelector('.katex annotation')?.textContent).toBe('**注意:**内容') expect(container.querySelector('pre code')?.textContent).toContain('**注意:**内容') expect(container.textContent).toContain('**Notice:**text') expect(container.textContent).toContain('*提醒!*继续') expect(container.textContent).toContain('**注意:**内容') }) it('links complete HTTP(S) inline code without promoting commands, unsafe schemes, or fences', () => { const localUrl = 'http://127.0.0.1:3199/?demo=1' const remoteUrl = 'https://example.com/preview?q=one%20two#result' const source = [ `\`${localUrl}\``, `\`${remoteUrl}\``, '`curl http://127.0.0.1:3199/?demo=1`', '`javascript:alert(1)`', '`mailto:dev@example.com`', `\` ${localUrl} \``, '```', localUrl, '```', ].join('\n\n') const { container } = render() const links = screen.getAllByRole('link') expect(links.map(link => link.getAttribute('href'))).toEqual([localUrl, remoteUrl]) for (const link of links) { expect(link.closest('code')).not.toBeNull() expect(link.getAttribute('target')).toBe('_blank') expect(link.getAttribute('rel')).toBe('noopener noreferrer') } links[0]?.focus() expect(document.activeElement).toBe(links[0]) expect(screen.getByText('curl http://127.0.0.1:3199/?demo=1').closest('a')).toBeNull() expect(screen.getByText('javascript:alert(1)').closest('a')).toBeNull() expect(screen.getByText('mailto:dev@example.com').closest('a')).toBeNull() const paddedCode = [...container.querySelectorAll('code')] .find(code => code.textContent === ` ${localUrl} `) expect(paddedCode?.querySelector('a')).toBeNull() expect(container.querySelector('pre code a')).toBeNull() }) it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => { const extension = cjkFriendlyStrong() expect(cjkFriendlyStrong()).toBe(extension) const construct = extension.text?.[42] const tokenizer = Array.isArray(construct) ? construct[0]?.tokenize : construct?.tokenize expect(tokenizer).toBeTypeOf('function') expect(() => tokenizer?.call({ parser: { constructs: { attentionMarkers: {} } }, previous: null, } as never, {} as never, () => undefined, () => undefined)).toThrow( 'micromark CommonMark attention markers are unavailable', ) }) it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => { for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) { const { container, unmount } = render() expect(container.querySelector('pre.shiki')).toBeNull() expect(container.querySelector('pre code')?.textContent).toContain('code body') unmount() } }) it('an empty fence keeps the stock pre; a language-less fence renders the plain CodeBlock arm', () => { const empty = render() expect(empty.container.querySelector('pre')?.outerHTML).toBe('
') const plain = render() expect(plain.container.querySelector('pre.shiki')).toBeNull() expect(plain.container.querySelector('pre code')?.textContent).toContain('no language here') }) it('streaming renders fences plain; the finalize swap highlights them', () => { const fence = '```ts\nconst answer = 42\n```' const live = render() expect(live.container.querySelector('pre.shiki')).toBeNull() expect(live.container.querySelector('pre code')?.textContent).toContain('const answer = 42') live.unmount() const done = render() expect(done.container.querySelector('pre.shiki')).not.toBeNull() }) it('forwards localized labels to fenced code blocks', () => { render() expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy() }) it('renders absolute HTTP(S) images with bounded presentation', () => { const markdown = [ '![secure diagram](https://example.com/secure.png)', '![plain diagram](http://example.com/plain.png)', ].join('\n\n') const { container } = render() const images = [...container.querySelectorAll('img')] expect(images.map(image => image.getAttribute('src'))).toEqual([ 'https://example.com/secure.png', 'http://example.com/plain.png', ]) for (const image of images) { expect(image.getAttribute('loading')).toBe('lazy') expect(image.getAttribute('decoding')).toBe('async') expect(image.getAttribute('referrerpolicy')).toBe('no-referrer') } }) it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => { const markdown = [ '', '', '[script](javascript:alert(1)) [relative](/settings)', '[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)', '![relative diagram](private.png)', '![absolute diagram](/workspace/private.png)', '![file diagram](file:///workspace/private.png)', '![script diagram](javascript:alert(1))', '![mail diagram](mailto:dev@example.com)', ].join('\n\n') const { container } = render() expect(container.querySelector('script')).toBeNull() expect(container.querySelector('img')).toBeNull() const neutralized = [...container.querySelectorAll('p')] .find(paragraph => paragraph.textContent === 'script relative') expect(neutralized?.querySelector('a')).toBeNull() expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull() expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer') expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank') expect(screen.getByText('relative diagram')).toBeTruthy() expect(screen.getByText('absolute diagram')).toBeTruthy() expect(screen.getByText('file diagram')).toBeTruthy() expect(screen.getByText('script diagram')).toBeTruthy() expect(screen.getByText('mail diagram')).toBeTruthy() }) it('keeps incomplete streaming Markdown renderable', () => { const { container } = render() expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy() expect(container.querySelectorAll('li')).toHaveLength(2) expect(screen.getByText('**unfinished')).toBeTruthy() }) it('renders inline and display TeX through KaTeX without enabling trusted commands', () => { const source = [ 'Einstein wrote $E = mc^2$.', '', '$$', '\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p', '$$', '', '$\\href{javascript:alert(1)}{unsafe}$', ].join('\n') const { container } = render() expect(container.querySelectorAll('.katex')).toHaveLength(3) expect(container.querySelectorAll('.katex-display')).toHaveLength(1) expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\partial \\mathbf{u}}') expect(container.querySelector('a')).toBeNull() }) it('renders common TeX delimiters and same-line tagged display blocks after the reply settles', () => { const source = [ 'Inline dollar $\\theta$ and backslash \\(\\frac{1}{5}\\).', '', '\\[\\frac{\\pi}{4} < \\theta < \\frac{\\pi}{2}\\]', '', '$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$', '', '| Symbol | Value |', '| --- | --- |', '| $\\theta$ | \\(\\frac{1}{5}\\) |', ].join('\n') const { container } = render() expect(container.querySelectorAll('.katex')).toHaveLength(6) expect(container.querySelectorAll('.katex-display')).toHaveLength(2) expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\pi}{4}') expect([...container.querySelectorAll('.katex-display')].at(-1)?.querySelector('annotation')?.textContent) .toContain('\\tag{1}') expect(container.querySelector('.katex-error')).toBeNull() expect(container.querySelector('table .katex')).not.toBeNull() }) it('keeps backslash delimiters correct across Markdown boundaries and malformed candidates', () => { const cases = [ { source: '\\(\\alpha \\, \\beta\\)', math: 1, display: 0, }, { source: String.raw`\\\(x\)`, math: 1, display: 0, value: 'x', }, { source: '\\(\\frac{1}{5}\n+\\frac{1}{7}\\)', math: 1, display: 0, value: '\\frac{1}{5}\n+\\frac{1}{7}', }, { source: '\\[a\\\\\nb\\]', math: 1, display: 1, value: 'a\\\\\nb', }, { source: '> \\[\n> \\frac{1}{5}\n> \\]', math: 1, display: 1, }, { source: '- \\[\n \\frac{1}{5}\n \\]', math: 1, display: 1, }, ] for (const item of cases) { const rendered = render() expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math) expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display) expect(rendered.container.querySelector('.katex-error')).toBeNull() if ('value' in item) { expect(rendered.container.querySelector('annotation')?.textContent).toBe(item.value) } rendered.unmount() } const literal = render() expect(literal.container.querySelectorAll('.katex')).toHaveLength(0) expect(literal.container.querySelector('.katex-display')).toBeNull() expect(literal.container.textContent).toContain('[x') }) it('keeps ordinary dollar blocks and incomplete delimiter candidates parseable', () => { const cases = [ { source: '$$\n\\theta\n$$', math: 1, display: 1 }, { source: '$$$\\theta$$$', math: 1, display: 0 }, { source: '$$a$b\nc', math: 0, display: 0 }, { source: ' \\[\n \\theta\n \\]', math: 1, display: 1 }, { source: '\\(\\theta', math: 0, display: 0 }, { source: String.raw`\(a\\)`, math: 0, display: 0 }, { source: '\\[\n\\[', math: 0, display: 0 }, { source: '> \\[\nnot a quoted continuation\n\\]', math: 0, display: 0 }, ] for (const item of cases) { const rendered = render() expect(rendered.container.querySelectorAll('.katex')).toHaveLength(item.math) expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(item.display) expect(rendered.container.querySelector('.katex-error')).toBeNull() rendered.unmount() } }) it('lets display math interrupt an open paragraph', () => { for (const source of ['Prose line\n\\[x\\]', 'Prose line\n$$x$$']) { const rendered = render() expect(rendered.container.querySelectorAll('p')).toHaveLength(1) expect(rendered.container.querySelectorAll('.katex-display')).toHaveLength(1) rendered.unmount() } }) it('leaves a dollar block with trailing text to upstream inline math', () => { const { container } = render() expect(container.querySelectorAll('.katex')).toHaveLength(1) expect(container.querySelector('.katex-display')).toBeNull() expect(container.querySelector('annotation')?.textContent).toBe('x') expect(container.textContent).toContain('trailing') }) it('renders escaped dollars and even backslash pairs before closing fences', () => { const source = [ String.raw`$$100\$$$`, '', String.raw`\(a\\\)`, '', String.raw`\[b\\\]`, ].join('\n') const { container } = render() const values = [...container.querySelectorAll('annotation')].map(node => node.textContent) expect(values).toEqual([String.raw`100\$`, String.raw`a\\`, String.raw`b\\`]) expect(container.querySelector('.katex-error')).toBeNull() }) it('bounds fallback work for repeated unclosed backslash delimiters', () => { const startedAt = performance.now() const { container } = render() expect(performance.now() - startedAt).toBeLessThan(1_000) expect(container.querySelector('.katex')).toBeNull() }) it('leaves TeX-looking fenced code literal', () => { const source = '```tex\n\\[\\frac{1}{5}\\]\n$$x \\tag{1}$$\n```' const { container } = render() expect(container.querySelector('.katex')).toBeNull() expect(container.querySelector('pre code')?.textContent).toContain('\\[\\frac{1}{5}\\]') expect(container.querySelector('pre code')?.textContent).toContain('$$x \\tag{1}$$') }) it('exposes the compatibility syntax as a micromark extension', () => { const extension = mathCompatibility() expect(Object.keys(extension)).toEqual(['flow', 'text']) expect(mathCompatibility()).toBe(extension) }) it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => { const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial' const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$' const live = render() expect(live.container.querySelector('.katex')).toBeNull() expect(live.container.querySelector('.katex-error')).toBeNull() expect(live.container.textContent).toContain('\\frac{\\partial \\mathbf{u}}{\\partial') live.rerender() expect(live.container.querySelectorAll('.katex')).toHaveLength(1) expect(live.container.querySelectorAll('.katex-display')).toHaveLength(1) expect(live.container.querySelector('.katex-error')).toBeNull() }) }) describe('JsonBlock', () => { it('collapsed by default; toggle reveals pretty-printed payload', () => { render() expect(screen.queryByText(/"a": 1/)).toBeNull() fireEvent.click(screen.getByRole('button', { name: /args/ })) expect(screen.getByText(/"a": 1/)).toBeDefined() fireEvent.click(screen.getByRole('button', { name: /args/ })) expect(screen.queryByText(/"a": 1/)).toBeNull() }) it('defaultOpen renders the body immediately', () => { const { container } = render() expect(container.querySelector('pre')?.textContent).toContain('1') }) it('stringifies undefined payloads via String()', () => { const { container } = render() expect(container.querySelector('pre')?.textContent).toBe('undefined') }) it('falls back to String() for circular payloads', () => { const circular: { self?: unknown } = {} circular.self = circular const { container } = render() expect(container.querySelector('pre')?.textContent).toBe('[object Object]') }) it('truncates beyond the size cap with a suffix note', () => { const big = 'x'.repeat(30_000) const { container } = render() const body = container.querySelector('pre')!.textContent expect(body.length).toBeLessThan(30_000) expect(body).toContain('截断') }) })