diff --git a/ui/desktop/src/components/MarkdownContent.test.tsx b/ui/desktop/src/components/MarkdownContent.test.tsx new file mode 100644 index 000000000000..ca4a4324864a --- /dev/null +++ b/ui/desktop/src/components/MarkdownContent.test.tsx @@ -0,0 +1,542 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/dom'; +import MarkdownContent from './MarkdownContent'; + +// Mock the icons to avoid import issues +vi.mock('./icons', () => ({ + Check: () =>
, + Copy: () =>
📋
, +})); + +describe('MarkdownContent', () => { + describe('HTML Security Integration', () => { + it('renders safe markdown content normally', async () => { + const content = `# Test Title + +Visit for more info. + +Contact for support. + +Use \`Array\` for generics.`; + + render(); + + await waitFor(() => { + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(screen.getByText(/Visit/)).toBeInTheDocument(); + expect(screen.getByText(/for more info/)).toBeInTheDocument(); + expect(screen.getByText(/Contact/)).toBeInTheDocument(); + expect(screen.getByText(/for support/)).toBeInTheDocument(); + }); + + // Should not create extra code blocks for safe content + const codeBlocks = screen.queryAllByText(/```html/); + expect(codeBlocks).toHaveLength(0); + }); + + it('wraps dangerous HTML in code blocks', async () => { + const content = `# Security Test + +This is safe text. + + + +More safe text.`; + + render(); + + await waitFor(() => { + expect(screen.getByText('Security Test')).toBeInTheDocument(); + expect(screen.getByText('This is safe text.')).toBeInTheDocument(); + expect(screen.getByText('More safe text.')).toBeInTheDocument(); + }); + + // The script tag should be in a code block, not executed + const scriptElements = document.querySelectorAll('script'); + expect(scriptElements).toHaveLength(0); // No actual script tags should be created + + // Should find the script content in a code block (text may be split across spans) + await waitFor(() => { + expect(screen.getByText(/alert/)).toBeInTheDocument(); + expect(screen.getByText(/xss/)).toBeInTheDocument(); + }); + }); + + it('handles HTML comments securely', async () => { + const content = `# Comment Test + + + +Normal text continues.`; + + render(); + + await waitFor(() => { + expect(screen.getByText('Comment Test')).toBeInTheDocument(); + expect(screen.getByText('Normal text continues.')).toBeInTheDocument(); + }); + + // Comment should be in a code block + await waitFor(() => { + expect(screen.getByText(/This is a malicious comment/)).toBeInTheDocument(); + }); + }); + + it('preserves existing code blocks', async () => { + const content = `# Code Block Test + +\`\`\`javascript +const html = "
This is safe in a code block
"; +console.log(html); +\`\`\` + +
This should be wrapped
`; + + render(); + + await waitFor(() => { + expect(screen.getByText('Code Block Test')).toBeInTheDocument(); + }); + + // Should preserve the original JavaScript code block (text may be split) + await waitFor(() => { + expect(screen.getByText(/const/)).toBeInTheDocument(); + expect(screen.getAllByText(/html/)).toHaveLength(2); // Variable name and function parameter + }); + + // The div outside the code block should be wrapped + await waitFor(() => { + expect(screen.getByText(/This should be wrapped/)).toBeInTheDocument(); + }); + }); + + it('handles mixed safe and unsafe content', async () => { + const content = `# Mixed Content Test + +1. Auto-link: +2. Inline code: \`const x = Array();\` +3. Real markup: +4. Placeholder path: /src`; + + render(); + + await waitFor(() => { + expect(screen.getByText('Mixed Content Test')).toBeInTheDocument(); + expect(screen.getByText(/Auto-link/)).toBeInTheDocument(); + expect(screen.getByText(/Inline code/)).toBeInTheDocument(); + expect(screen.getByText(/Real markup/)).toBeInTheDocument(); + expect(screen.getByText(/Placeholder path/)).toBeInTheDocument(); + }); + + // Only the input tag should be wrapped + await waitFor(() => { + expect(screen.getByText(/input/)).toBeInTheDocument(); + expect(screen.getByText(/type/)).toBeInTheDocument(); + expect(screen.getByText(/disabled/)).toBeInTheDocument(); + }); + + // Should not have actual input elements in the DOM + const inputElements = document.querySelectorAll('input'); + expect(inputElements).toHaveLength(0); + }); + }); + + describe('Code Block Functionality', () => { + it('renders code blocks with syntax highlighting', async () => { + const content = `\`\`\`javascript +console.log('Hello, World!'); +\`\`\``; + + render(); + + await waitFor(() => { + expect(screen.getByText(/console/)).toBeInTheDocument(); + expect(screen.getByText(/log/)).toBeInTheDocument(); + expect(screen.getByText(/Hello, World!/)).toBeInTheDocument(); + }); + }); + + it('renders inline code', async () => { + const content = 'Use `console.log()` to debug.'; + + render(); + + await waitFor(() => { + expect(screen.getByText(/Use/)).toBeInTheDocument(); + expect(screen.getByText(/to debug/)).toBeInTheDocument(); + expect(screen.getByText('console.log()')).toBeInTheDocument(); + }); + }); + }); + + describe('Markdown Features', () => { + it('renders headers correctly', async () => { + const content = `# H1 Header +## H2 Header +### H3 Header`; + + render(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1, name: 'H1 Header' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'H2 Header' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'H3 Header' })).toBeInTheDocument(); + }); + }); + + it('renders lists correctly', async () => { + const content = `- Item 1 +- Item 2 +- Item 3 + +1. Numbered 1 +2. Numbered 2`; + + render(); + + await waitFor(() => { + expect(screen.getByText('Item 1')).toBeInTheDocument(); + expect(screen.getByText('Item 2')).toBeInTheDocument(); + expect(screen.getByText('Item 3')).toBeInTheDocument(); + expect(screen.getByText('Numbered 1')).toBeInTheDocument(); + expect(screen.getByText('Numbered 2')).toBeInTheDocument(); + }); + }); + + it('renders links with correct attributes', async () => { + const content = '[Visit Block](https://block.dev)'; + + render(); + + await waitFor(() => { + const link = screen.getByRole('link', { name: 'Visit Block' }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', 'https://block.dev'); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + }); + + it('renders tables correctly', async () => { + const content = `| Name | Value | +|------|-------| +| Test | 123 | +| Demo | 456 |`; + + render(); + + await waitFor(() => { + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Value')).toBeInTheDocument(); + expect(screen.getByText('Test')).toBeInTheDocument(); + expect(screen.getByText('123')).toBeInTheDocument(); + expect(screen.getByText('Demo')).toBeInTheDocument(); + expect(screen.getByText('456')).toBeInTheDocument(); + }); + }); + }); + + describe('Error Handling', () => { + it('handles empty content gracefully', async () => { + render(); + + // Should not throw and should render the component + const container = document.querySelector('.w-full.overflow-x-hidden'); + expect(container).toBeInTheDocument(); + }); + + it('handles malformed markdown gracefully', async () => { + const content = `# Unclosed header +[Unclosed link(https://example.com +\`\`\` +Unclosed code block`; + + render(); + + await waitFor(() => { + // Should still render what it can + expect(screen.getByText('Unclosed header')).toBeInTheDocument(); + }); + }); + }); + + describe('Line Break Functionality', () => { + it('preserves single line breaks with remark-breaks plugin', async () => { + const content = `First line +Second line +Third line`; + + const { container } = render(); + + await waitFor(() => { + // Check that all text content is present (text may be split by
tags) + expect(container).toHaveTextContent('First line'); + expect(container).toHaveTextContent('Second line'); + expect(container).toHaveTextContent('Third line'); + }); + + // Check that line breaks are preserved (rendered as
tags) + const brElements = container.querySelectorAll('br'); + expect(brElements.length).toBeGreaterThan(0); + }); + + it('handles mixed content with line breaks', async () => { + const content = `# Header +Paragraph with +line breaks. + +- List item 1 +- List item 2`; + + const { container } = render(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1, name: 'Header' })).toBeInTheDocument(); + + // Check that text content is present (text may be split by
tags) + expect(container).toHaveTextContent('Paragraph with'); + expect(container).toHaveTextContent('line breaks.'); + expect(screen.getByText('List item 1')).toBeInTheDocument(); + expect(screen.getByText('List item 2')).toBeInTheDocument(); + }); + }); + + it('maintains existing markdown features with line breaks', async () => { + const content = `**Bold text** +with line break + +\`code\` and +more text`; + + const { container } = render(); + + await waitFor(() => { + // Bold text should still work + const boldElement = container.querySelector('strong'); + expect(boldElement).toBeInTheDocument(); + expect(boldElement).toHaveTextContent('Bold text'); + + // Code should still work + expect(screen.getByText('code')).toBeInTheDocument(); + }); + }); + }); + + describe('URL Overflow Handling', () => { + it('handles very long URLs without overflow', async () => { + const longUrl = + 'https://example-docs.com/document/d/1oruk3lcrnhoOXMFzBJB8X6qQ5AtQTmj4XXxXk3xK-3g/edit?usp=sharing&mode=edit&version=1'; + const content = `Check out this document: ${longUrl} + +Another very long URL: https://www.example.com/very/long/path/with/many/segments/and/parameters?param1=value1¶m2=value2¶m3=value3¶m4=value4¶m5=value5`; + + const { container } = render(); + + await waitFor(() => { + expect(screen.getByText(/Check out this document/)).toBeInTheDocument(); + expect(screen.getByText(/Another very long URL/)).toBeInTheDocument(); + }); + + // Check that URLs are rendered as links + const links = container.querySelectorAll('a'); + expect(links.length).toBeGreaterThan(0); + + // Check that links have proper CSS classes for word breaking + links.forEach((link) => { + // The CSS should allow the text to break + expect(link).toBeInTheDocument(); + }); + }); + + it('handles markdown links with long URLs', async () => { + const longUrl = + 'https://example-docs.com/document/d/1oruk3lcrnhoOXMFzBJB8X6qQ5AtQTmj4XXxXk3xK-3g/edit?usp=sharing&mode=edit&version=1'; + const content = `[Click here for the document](${longUrl})`; + + render(); + + await waitFor(() => { + const link = screen.getByRole('link', { name: 'Click here for the document' }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', longUrl); + }); + }); + + it('handles multiple long URLs in the same message', async () => { + const content = `Here are some long URLs: + +1. Example Doc: https://example-docs.com/document/d/1oruk3lcrnhoOXMFzBJB8X6qQ5AtQTmj4XXxXk3xK-3g/edit?usp=sharing&mode=edit&version=1 +2. Another long URL: https://www.example.com/very/long/path/with/many/segments/and/parameters?param1=value1¶m2=value2¶m3=value3 +3. Third URL: https://api.example.com/v1/users/12345/documents/67890/attachments/abcdef123456789?format=json&include=metadata&sort=created_at`; + + render(); + + await waitFor(() => { + expect(screen.getByText(/Here are some long URLs/)).toBeInTheDocument(); + expect(screen.getByText(/Example Doc/)).toBeInTheDocument(); + expect(screen.getByText(/Another long URL/)).toBeInTheDocument(); + expect(screen.getByText(/Third URL/)).toBeInTheDocument(); + }); + }); + + it('applies word-break CSS classes to the container', () => { + const content = 'Test content'; + render(); + + const markdownContainer = document.querySelector('.prose'); + expect(markdownContainer).toBeInTheDocument(); + expect(markdownContainer).toHaveClass('prose-a:break-all'); + expect(markdownContainer).toHaveClass('prose-a:overflow-wrap-anywhere'); + }); + }); + + describe('LaTeX/KaTeX Escaping', () => { + it('escapes underscores in plain text to prevent subscript rendering', async () => { + const content = 'This is a variable_name and another_variable_name in text.'; + + const { container } = render(); + + await waitFor(() => { + expect(container).toHaveTextContent('This is a variable_name and another_variable_name in text.'); + }); + + // Should not have any subscript elements + const subscripts = container.querySelectorAll('sub'); + expect(subscripts).toHaveLength(0); + }); + + it('escapes dollar signs in shell commands to prevent math mode', async () => { + const content = 'Run this command: cmd "$FOO" --opt="$BAR"'; + + const { container } = render(); + + await waitFor(() => { + expect(container).toHaveTextContent('Run this command: cmd "$FOO" --opt="$BAR"'); + }); + + // Should not have any KaTeX math elements + const mathElements = container.querySelectorAll('.katex'); + expect(mathElements).toHaveLength(0); + }); + + it('preserves underscores inside inline code', async () => { + const content = 'Use `variable_name` and `another_variable` in your code.'; + + const { container } = render(); + + await waitFor(() => { + expect(screen.getByText('variable_name')).toBeInTheDocument(); + expect(screen.getByText('another_variable')).toBeInTheDocument(); + }); + + // Underscores in code should be preserved + const codeElements = container.querySelectorAll('code'); + expect(codeElements.length).toBeGreaterThan(0); + }); + + it('preserves dollar signs inside code blocks', async () => { + const content = `\`\`\`bash +cmd "$FOO" --opt="$BAR" +echo "$HOME" +\`\`\``; + + const { container } = render(); + + await waitFor(() => { + expect(container).toHaveTextContent('cmd "$FOO" --opt="$BAR"'); + expect(container).toHaveTextContent('echo "$HOME"'); + }); + + // Should not have any KaTeX math elements + const mathElements = container.querySelectorAll('.katex'); + expect(mathElements).toHaveLength(0); + }); + + it('handles mixed content with underscores in text and code', async () => { + const content = `This is some_variable in text. + +\`\`\`python +def some_function(): + return some_variable +\`\`\` + +And more text_with_underscores here.`; + + const { container } = render(); + + await waitFor(() => { + expect(container).toHaveTextContent('This is some_variable in text.'); + expect(container).toHaveTextContent('def some_function():'); + expect(container).toHaveTextContent('And more text_with_underscores here.'); + }); + + // Should not have any subscript elements + const subscripts = container.querySelectorAll('sub'); + expect(subscripts).toHaveLength(0); + }); + + it('allows actual LaTeX math when explicitly written', async () => { + const content = 'The equation is: $x^2 + y^2 = z^2$'; + + const { container } = render(); + + await waitFor(() => { + // The math should be rendered (or at least attempted) + // Since we're escaping $ signs, this will NOT render as math + // This test verifies our escaping is working + expect(container).toHaveTextContent('The equation is: $x^2 + y^2 = z^2$'); + }); + }); + + it('handles file paths with underscores', async () => { + const content = 'Check the file at /path/to/my_file_name.txt and /another/path_to/file.log'; + + const { container } = render(); + + await waitFor(() => { + expect(container).toHaveTextContent('/path/to/my_file_name.txt'); + expect(container).toHaveTextContent('/another/path_to/file.log'); + }); + + // Should not have any subscript elements + const subscripts = container.querySelectorAll('sub'); + expect(subscripts).toHaveLength(0); + }); + + it('handles environment variables in text', async () => { + const content = 'Set $HOME and $PATH variables, also check $USER.'; + + const { container } = render(); + + await waitFor(() => { + expect(container).toHaveTextContent('Set $HOME and $PATH variables, also check $USER.'); + }); + + // Should not have any KaTeX math elements + const mathElements = container.querySelectorAll('.katex'); + expect(mathElements).toHaveLength(0); + }); + + it('handles log output with underscores and dollar signs', async () => { + const content = `Command output: +test_function_name called with $arg1="value" and $arg2="other_value" +Error in module_name at line_number 42`; + + const { container } = render(); + + await waitFor(() => { + expect(container).toHaveTextContent('test_function_name'); + expect(container).toHaveTextContent('$arg1="value"'); + expect(container).toHaveTextContent('other_value'); + expect(container).toHaveTextContent('module_name'); + }); + + // Should not have subscripts or math elements + const subscripts = container.querySelectorAll('sub'); + const mathElements = container.querySelectorAll('.katex'); + expect(subscripts).toHaveLength(0); + expect(mathElements).toHaveLength(0); + }); + }); +}); diff --git a/ui/desktop/src/components/MarkdownContent.tsx b/ui/desktop/src/components/MarkdownContent.tsx new file mode 100644 index 000000000000..a665f1239acb --- /dev/null +++ b/ui/desktop/src/components/MarkdownContent.tsx @@ -0,0 +1,258 @@ +import React, { useState, useEffect, useRef, memo, useMemo } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import remarkBreaks from 'remark-breaks'; +import remarkMath from 'remark-math'; +import rehypeKatex from 'rehype-katex'; +import 'katex/dist/katex.min.css'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; +// Improved oneDark theme for better comment contrast and readability +const customOneDarkTheme = { + ...oneDark, + 'code[class*="language-"]': { + ...oneDark['code[class*="language-"]'], + color: '#e6e6e6', + fontSize: '14px', + }, + 'pre[class*="language-"]': { + ...oneDark['pre[class*="language-"]'], + color: '#e6e6e6', + fontSize: '14px', + }, + comment: { ...oneDark.comment, color: '#a0a0a0', fontStyle: 'italic' }, + prolog: { ...oneDark.prolog, color: '#a0a0a0' }, + doctype: { ...oneDark.doctype, color: '#a0a0a0' }, + cdata: { ...oneDark.cdata, color: '#a0a0a0' }, +}; + +import { Check, Copy } from './icons'; +import { wrapHTMLInCodeBlock } from '../utils/htmlSecurity'; + +interface CodeProps extends React.ClassAttributes, React.HTMLAttributes { + inline?: boolean; +} + +interface MarkdownContentProps { + content: string; + className?: string; +} + +// Memoized CodeBlock component to prevent re-rendering when props haven't changed +const CodeBlock = memo(function CodeBlock({ + language, + children, +}: { + language: string; + children: string; +}) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef(null); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(children); + setCopied(true); + + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + + timeoutRef.current = window.setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy text: ', err); + } + }; + + useEffect(() => { + return () => { + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); + + // Memoize the SyntaxHighlighter component to prevent re-rendering + // Only re-render if language or children change + const memoizedSyntaxHighlighter = useMemo(() => { + // For very large code blocks, consider truncating or lazy loading + const isLargeCodeBlock = children.length > 10000; // 10KB threshold + + if (isLargeCodeBlock) { + console.log(`Large code block detected (${children.length} chars), consider optimization`); + } + + return ( + + {children} + + ); + }, [language, children]); + + return ( +
+ +
{memoizedSyntaxHighlighter}
+
+ ); +}); + +const MarkdownCode = memo( + React.forwardRef(function MarkdownCode( + { inline, className, children, ...props }: CodeProps, + ref: React.Ref + ) { + const match = /language-(\w+)/.exec(className || ''); + return !inline && match ? ( + {String(children).replace(/\n$/, '')} + ) : ( + + {children} + + ); + }) +); + +/** + * Escapes special characters that would be interpreted as LaTeX/KaTeX syntax, + * but only outside of code blocks and inline code. + * This prevents issues like: + * - `_` being interpreted as subscript + * - `$...$` being interpreted as inline math (e.g., in shell commands like `cmd "$FOO"`) + */ +function escapeLatexOutsideCode(content: string): string { + const parts: string[] = []; + let lastIndex = 0; + + // Match code blocks (```...```) and inline code (`...`) + // This regex captures: + // - Fenced code blocks: ```lang\n...\n``` + // - Inline code: `...` + const codePattern = /(```[\s\S]*?```|`[^`\n]+?`)/g; + + let match; + while ((match = codePattern.exec(content)) !== null) { + // Add the text before this code block/inline code (with escaping) + const textBefore = content.slice(lastIndex, match.index); + parts.push(escapeLatexChars(textBefore)); + + // Add the code block/inline code as-is (no escaping) + parts.push(match[0]); + + lastIndex = match.index + match[0].length; + } + + // Add any remaining text after the last code block (with escaping) + if (lastIndex < content.length) { + parts.push(escapeLatexChars(content.slice(lastIndex))); + } + + return parts.join(''); +} + +/** + * Escapes LaTeX special characters in plain text. + * Escapes: + * - `_` to `\_` (prevents subscript interpretation) + * - `$` to `\$` (prevents math mode interpretation) + */ +function escapeLatexChars(text: string): string { + return text + .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/_/g, '\\_') // Escape underscores + .replace(/\$/g, '\\$'); // Escape dollar signs +} + +const MarkdownContent = memo(function MarkdownContent({ + content, + className = '', +}: MarkdownContentProps) { + const [processedContent, setProcessedContent] = useState(content); + + useEffect(() => { + try { + // First wrap HTML in code blocks for security + let processed = wrapHTMLInCodeBlock(content); + // Then escape LaTeX special characters outside of code blocks + processed = escapeLatexOutsideCode(processed); + setProcessedContent(processed); + } catch (error) { + console.error('Error processing content:', error); + // Fallback to original content if processing fails + setProcessedContent(content); + } + }, [content]); + + return ( + + ); +}); + +export default MarkdownContent;