From 6542142840f881b1a7efb1ca2b49118daeb4cc11 Mon Sep 17 00:00:00 2001 From: simonguo Date: Thu, 3 Jul 2025 19:30:29 +0800 Subject: [PATCH] Update MarkdownRenderer and add test files --- __tests__/CodeView-test.js | 53 ++++++++++ __tests__/MarkdownRenderer-test.js | 152 +++++++++++++++++++++++++++++ src/CodeView.tsx | 11 ++- src/MarkdownRenderer.tsx | 22 ++++- 4 files changed, 231 insertions(+), 7 deletions(-) create mode 100644 __tests__/CodeView-test.js create mode 100644 __tests__/MarkdownRenderer-test.js diff --git a/__tests__/CodeView-test.js b/__tests__/CodeView-test.js new file mode 100644 index 0000000..9a0250f --- /dev/null +++ b/__tests__/CodeView-test.js @@ -0,0 +1,53 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import CodeView from '../src/CodeView'; +import MarkdownRenderer from '../src/MarkdownRenderer'; + +// Mock the MarkdownRenderer component +jest.mock('../src/MarkdownRenderer', () => + jest.fn(({ children }) => ( +
{children}
+ )) +); + +describe('CodeView', () => { + it('renders without crashing', () => { + render(); + expect(screen.getByTestId('mock-markdown-renderer')).toBeInTheDocument(); + }); + + it('passes copyButtonProps to MarkdownRenderer', () => { + const mockCopyButtonProps = { + 'data-testid': 'custom-copy-btn', + className: 'custom-class', + }; + + render( + + ); + + // Check if MarkdownRenderer was called with the correct props + expect(MarkdownRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + copyButtonProps: mockCopyButtonProps + }), + expect.anything() + ); + }); + + it('passes sourceCode to Renderer', () => { + const sourceCode = 'const a = 1;'; + render(); + + // Check if the source code is passed down to the MarkdownRenderer + expect(MarkdownRenderer).toHaveBeenCalledWith( + expect.objectContaining({ + children: sourceCode + }), + expect.anything() + ); + }); +}); diff --git a/__tests__/MarkdownRenderer-test.js b/__tests__/MarkdownRenderer-test.js new file mode 100644 index 0000000..0a45171 --- /dev/null +++ b/__tests__/MarkdownRenderer-test.js @@ -0,0 +1,152 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import copy from 'copy-to-clipboard'; +import MarkdownRenderer from '../src/MarkdownRenderer'; + +// Mock the copy-to-clipboard library +jest.mock('copy-to-clipboard'); + +// Mock document.execCommand which is used as a fallback +beforeEach(() => { + document.execCommand = jest.fn(); + document.createRange = () => ({ + setStart: () => true, + setEnd: () => true, + commonAncestorContainer: { + nodeName: 'BODY', + ownerDocument: document, + }, + }); + window.getSelection = () => ({ + removeAllRanges: () => undefined, + addRange: () => undefined, + toString: () => '', + }); + + // Mock the clipboard API + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: jest.fn().mockResolvedValue(undefined), + }, + writable: true, + configurable: true, + }); + + // Reset all mocks + jest.clearAllMocks(); +}); + +afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); +}); + +describe('MarkdownRenderer', () => { + it('renders without crashing', () => { + render({'

Test

'}
); + expect(screen.getByText('Test').tagName).toBe('H2'); + }); + + it('renders code blocks with copy button', () => { + const { container } = render( + + {`
const a = 1;
`} +
+ ); + + const codeBlock = container.querySelector('.rcv-code-renderer'); + expect(codeBlock).toBeInTheDocument(); + + // Check if copy button is rendered with the correct class + const copyButton = container.querySelector('button[title="Copy code"]'); + expect(copyButton).toBeInTheDocument(); + expect(copyButton).toHaveClass('btn-copy-code'); + }); + + it('applies custom copy button props', () => { + const customProps = { + 'data-testid': 'custom-copy-button', + class: 'custom-button-class', + onClick: jest.fn(), + }; + + const { container } = render( + + {`
const a = 1;
`} +
+ ); + + const copyButton = container.querySelector('button[title="Copy code"]'); + expect(copyButton).toHaveAttribute('data-testid', 'custom-copy-button'); + expect(copyButton.getAttribute('class')).toEqual('custom-button-class'); + expect(copyButton.getAttribute('data-testid')).toEqual('custom-copy-button'); + }); + + it('copies code to clipboard when copy button is clicked', () => { + const { container } = render( + + {`
const test = 'test';
`} +
+ ); + + const copyButton = container.querySelector('button[title="Copy code"]'); + fireEvent.click(copyButton); + + // Check if copy-to-clipboard was called with the correct code + expect(copy).toHaveBeenCalledWith('const test = \'test\';'); + }); + + it('copies code using copy-to-clipboard', () => { + const { container } = render( + + {`
const test = 'test';
`} +
+ ); + + const copyButton = container.querySelector('button[title="Copy code"]'); + fireEvent.click(copyButton); + + // Verify copy was called with the correct text (including the semicolon) + expect(copy).toHaveBeenCalledWith('const test = \'test\';'); + + // Verify the copy button exists and has the correct title + expect(copyButton).toBeInTheDocument(); + expect(copyButton).toHaveAttribute('title', 'Copy code'); + + // Verify the copy function was called with the correct text + expect(copy).toHaveBeenCalledWith('const test = \'test\';'); + }); + + it('adds copy button by default', () => { + const { container } = render( + + {`
with copy
`} +
+ ); + + const copyButton = container.querySelector('button[title="Copy code"]'); + expect(copyButton).toBeInTheDocument(); + }); + + it('handles multiple code blocks', () => { + const { container } = render( + + {` +
first block
+
second block
+ `} +
+ ); + + const copyButtons = container.querySelectorAll('button[title="Copy code"]'); + expect(copyButtons).toHaveLength(2); + + // Test first button + fireEvent.click(copyButtons[0]); + expect(copy).toHaveBeenCalledWith('first block'); + + // Test second button + fireEvent.click(copyButtons[1]); + expect(copy).toHaveBeenCalledWith('second block'); + }); +}); diff --git a/src/CodeView.tsx b/src/CodeView.tsx index fd26b8f..588fec3 100644 --- a/src/CodeView.tsx +++ b/src/CodeView.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ import React from 'react'; import MarkdownRenderer from './MarkdownRenderer'; import parseHTML from './utils/parseHTML'; @@ -10,6 +9,9 @@ export interface CodeViewProps extends RendererProps { /** The code to be rendered is executed */ sourceCode?: string; + + /** The properties of the copy button */ + copyButtonProps?: React.HTMLAttributes; } const CodeView = React.forwardRef((props: CodeViewProps, ref: React.Ref) => { @@ -21,6 +23,7 @@ const CodeView = React.forwardRef((props: CodeViewProps, ref: React.Ref ); } else if (fragment.type === 'html') { - return {fragment.content}; + return ( + + {fragment.content} + + ); } })} diff --git a/src/MarkdownRenderer.tsx b/src/MarkdownRenderer.tsx index c05f4f9..3c4e6a0 100644 --- a/src/MarkdownRenderer.tsx +++ b/src/MarkdownRenderer.tsx @@ -7,18 +7,22 @@ import { iconPath as checkPath } from './icons/Check'; interface MarkdownRendererProps extends React.HTMLAttributes { children?: string | null; + copyButtonProps?: React.HTMLAttributes; } -function appendCopyButton(container?: HTMLDivElement | null) { +function appendCopyButton( + container?: HTMLDivElement | null, + buttonProps?: React.HTMLAttributes +) { if (!container) { return; } const button = document.createElement('button'); - button.className = - 'copy-code-button rs-btn-icon rs-btn-icon-circle rs-btn rs-btn-subtle rs-btn-xs'; + button.className = 'btn-copy-code'; button.title = 'Copy code'; button.innerHTML = svgTpl(copyPath); + button.onclick = e => { e.preventDefault(); const code = container?.querySelector('code')?.textContent; @@ -33,18 +37,26 @@ function appendCopyButton(container?: HTMLDivElement | null) { icon?.setAttribute('d', copyPath); }, 2000); }; + + if (buttonProps) { + Object.entries(buttonProps || {}).forEach(([key, value]) => { + button.setAttribute(key, value); + }); + } + container?.appendChild(button); } const MarkdownRenderer = React.forwardRef( (props: MarkdownRendererProps, ref: React.Ref) => { - const { children, className, ...rest } = props; + const { children, className, copyButtonProps, ...rest } = props; const mdRef = React.useRef(null); useEffect(() => { mdRef.current?.querySelectorAll('.rcv-code-renderer').forEach((el: any) => { - appendCopyButton(el); + appendCopyButton(el, copyButtonProps); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); if (!children) {