mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 10:28:10 +08:00
test: add unit tests for base components-part-4 (#32452)
Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
This commit is contained in:
54
web/app/components/base/markdown/error-boundary.spec.tsx
Normal file
54
web/app/components/base/markdown/error-boundary.spec.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ErrorBoundary from './error-boundary'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
describe('ErrorBoundary', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('renders children when there is no error', () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div data-testid="child">Hello world</div>
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('child')).toHaveTextContent('Hello world')
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('catches errors thrown in children, shows fallback UI and logs the error', () => {
|
||||
const testError = new Error('Test render error')
|
||||
|
||||
const Thrower: React.FC = () => {
|
||||
throw testError
|
||||
}
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Thrower />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByText(/Oops! An error occurred/i),
|
||||
).toBeInTheDocument()
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
|
||||
const hasLoggedOurError = consoleErrorSpy.mock.calls.some((call: unknown[]) =>
|
||||
call.includes(testError),
|
||||
)
|
||||
|
||||
expect(hasLoggedOurError).toBe(true)
|
||||
})
|
||||
})
|
||||
123
web/app/components/base/markdown/index.spec.tsx
Normal file
123
web/app/components/base/markdown/index.spec.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import type { SimplePluginInfo } from './react-markdown-wrapper'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Markdown } from './index'
|
||||
|
||||
const { mockReactMarkdownWrapper } = vi.hoisted(() => ({
|
||||
mockReactMarkdownWrapper: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: () => (props: { latexContent: string }) => {
|
||||
mockReactMarkdownWrapper(props)
|
||||
return <div data-testid="react-markdown-wrapper">{props.latexContent}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
type CapturedProps = {
|
||||
latexContent: string
|
||||
pluginInfo?: SimplePluginInfo
|
||||
customComponents?: Record<string, unknown>
|
||||
customDisallowedElements?: string[]
|
||||
rehypePlugins?: unknown[]
|
||||
}
|
||||
|
||||
const getLastWrapperProps = (): CapturedProps => {
|
||||
const calls = mockReactMarkdownWrapper.mock.calls
|
||||
const lastCall = calls[calls.length - 1]
|
||||
return lastCall[0] as CapturedProps
|
||||
}
|
||||
|
||||
describe('Markdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render wrapper content', () => {
|
||||
render(<Markdown content="Hello World" />)
|
||||
expect(screen.getByTestId('react-markdown-wrapper')).toHaveTextContent('Hello World')
|
||||
})
|
||||
|
||||
it('should apply default classes', () => {
|
||||
const { container } = render(<Markdown content="Test" />)
|
||||
const markdownDiv = container.querySelector('.markdown-body')
|
||||
expect(markdownDiv).toHaveClass('markdown-body', '!text-text-primary')
|
||||
})
|
||||
|
||||
it('should merge custom className with default classes', () => {
|
||||
const { container } = render(<Markdown content="Test" className="custom another" />)
|
||||
const markdownDiv = container.querySelector('.markdown-body')
|
||||
expect(markdownDiv).toHaveClass('markdown-body', '!text-text-primary', 'custom', 'another')
|
||||
})
|
||||
|
||||
it('should not include undefined in className', () => {
|
||||
const { container } = render(<Markdown content="Test" className={undefined} />)
|
||||
const markdownDiv = container.querySelector('.markdown-body')
|
||||
expect(markdownDiv?.className).not.toContain('undefined')
|
||||
})
|
||||
|
||||
it('should preprocess think tags', () => {
|
||||
render(<Markdown content="<think>Thought</think>" />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.latexContent).toContain('<details data-think=true>')
|
||||
expect(props.latexContent).toContain('Thought')
|
||||
expect(props.latexContent).toContain('[ENDTHINKFLAG]</details>')
|
||||
})
|
||||
|
||||
it('should preprocess latex block notation', () => {
|
||||
render(<Markdown content={'\\[x^2 + y^2 = z^2\\]'} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.latexContent).toContain('$$x^2 + y^2 = z^2$$')
|
||||
})
|
||||
|
||||
it('should preprocess latex parentheses notation', () => {
|
||||
render(<Markdown content={'Inline \\(a + b\\) equation'} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.latexContent).toContain('$$a + b$$')
|
||||
})
|
||||
|
||||
it('should preserve latex inside code blocks', () => {
|
||||
render(<Markdown content={'```\n$E = mc^2$\n```'} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.latexContent).toContain('$E = mc^2$')
|
||||
})
|
||||
|
||||
it('should pass pluginInfo through', () => {
|
||||
const pluginInfo = {
|
||||
pluginUniqueIdentifier: 'plugin-unique',
|
||||
pluginId: 'plugin-id',
|
||||
}
|
||||
render(<Markdown content="content" pluginInfo={pluginInfo} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.pluginInfo).toEqual(pluginInfo)
|
||||
})
|
||||
|
||||
it('should pass default empty customComponents when omitted', () => {
|
||||
render(<Markdown content="content" />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.customComponents).toEqual({})
|
||||
})
|
||||
|
||||
it('should pass customComponents through', () => {
|
||||
const customComponents = {
|
||||
h1: ({ children }: { children: React.ReactNode }) => <h1>{children}</h1>,
|
||||
}
|
||||
render(<Markdown content="# title" customComponents={customComponents} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.customComponents).toBe(customComponents)
|
||||
})
|
||||
|
||||
it('should pass customDisallowedElements through', () => {
|
||||
const customDisallowedElements = ['strong', 'em']
|
||||
render(<Markdown content="**bold**" customDisallowedElements={customDisallowedElements} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.customDisallowedElements).toBe(customDisallowedElements)
|
||||
})
|
||||
|
||||
it('should pass rehypePlugins through', () => {
|
||||
const plugin = () => (tree: unknown) => tree
|
||||
const rehypePlugins = [plugin]
|
||||
render(<Markdown content="content" rehypePlugins={rehypePlugins} />)
|
||||
const props = getLastWrapperProps()
|
||||
expect(props.rehypePlugins).toBe(rehypePlugins)
|
||||
})
|
||||
})
|
||||
157
web/app/components/base/markdown/markdown-utils.spec.ts
Normal file
157
web/app/components/base/markdown/markdown-utils.spec.ts
Normal file
@ -0,0 +1,157 @@
|
||||
// app/components/base/markdown/preprocess.spec.ts
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Helper to (re)load the module with a mocked config value.
|
||||
* We need to reset modules because the tested module imports
|
||||
* ALLOW_UNSAFE_DATA_SCHEME at top-level.
|
||||
*/
|
||||
const loadModuleWithConfig = async (allowDataScheme: boolean) => {
|
||||
vi.resetModules()
|
||||
vi.doMock('@/config', () => ({ ALLOW_UNSAFE_DATA_SCHEME: allowDataScheme }))
|
||||
return await import('./markdown-utils')
|
||||
}
|
||||
|
||||
describe('preprocessLaTeX', () => {
|
||||
let mod: typeof import('./markdown-utils')
|
||||
|
||||
beforeEach(async () => {
|
||||
// config value doesn't matter for LaTeX preprocessing, mock it false
|
||||
mod = await loadModuleWithConfig(false)
|
||||
})
|
||||
|
||||
it('returns non-string input unchanged', () => {
|
||||
// call with a non-string (bypass TS type system)
|
||||
// @ts-expect-error test
|
||||
const out = mod.preprocessLaTeX(123)
|
||||
expect(out).toBe(123)
|
||||
})
|
||||
|
||||
it('converts \\[ ... \\] into $$ ... $$', () => {
|
||||
const input = 'This is math: \\[x^2 + 1\\]'
|
||||
const out = mod.preprocessLaTeX(input)
|
||||
expect(out).toContain('$$x^2 + 1$$')
|
||||
})
|
||||
|
||||
it('converts \\( ... \\) into $$ ... $$', () => {
|
||||
const input = 'Inline: \\(a+b\\)'
|
||||
const out = mod.preprocessLaTeX(input)
|
||||
expect(out).toContain('$$a+b$$')
|
||||
})
|
||||
|
||||
it('preserves code blocks (does not transform $ inside them)', () => {
|
||||
const input = [
|
||||
'Some text before',
|
||||
'```js',
|
||||
'const s = \'$insideCode$\'',
|
||||
'```',
|
||||
'And outside $math$',
|
||||
].join('\n')
|
||||
|
||||
const out = mod.preprocessLaTeX(input)
|
||||
|
||||
// code block should be preserved exactly (including $ inside)
|
||||
expect(out).toContain('```js\nconst s = \'$insideCode$\'\n```')
|
||||
// outside inline $math$ should remain intact (function keeps inline $...$)
|
||||
expect(out).toContain('$math$')
|
||||
})
|
||||
|
||||
it('does not treat escaped dollar \\$ as math delimiter', () => {
|
||||
const input = 'Price: \\$5 and math $x$'
|
||||
const out = mod.preprocessLaTeX(input)
|
||||
// escaped dollar should remain escaped
|
||||
expect(out).toContain('\\$5')
|
||||
// math should still be present
|
||||
expect(out).toContain('$x$')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preprocessThinkTag', () => {
|
||||
let mod: typeof import('./markdown-utils')
|
||||
|
||||
beforeEach(async () => {
|
||||
mod = await loadModuleWithConfig(false)
|
||||
})
|
||||
|
||||
it('transforms single <think>...</think> into details with data-think and ENDTHINKFLAG', () => {
|
||||
const input = '<think>this is a thought</think>'
|
||||
const out = mod.preprocessThinkTag(input)
|
||||
|
||||
expect(out).toContain('<details data-think=true>')
|
||||
expect(out).toContain('this is a thought')
|
||||
expect(out).toContain('[ENDTHINKFLAG]</details>')
|
||||
})
|
||||
|
||||
it('handles multiple <think> tags and inserts newline after closing </details>', () => {
|
||||
const input = '<think>one</think>\n<think>two</think>'
|
||||
const out = mod.preprocessThinkTag(input)
|
||||
|
||||
// both thoughts become details blocks
|
||||
const occurrences = (out.match(/<details data-think=true>/g) || []).length
|
||||
expect(occurrences).toBe(2)
|
||||
|
||||
// ensure ENDTHINKFLAG is present twice
|
||||
const endCount = (out.match(/\[ENDTHINKFLAG\]<\/details>/g) || []).length
|
||||
expect(endCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('customUrlTransform', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('allows fragments (#foo) and protocol-relative (//host) and relative paths', async () => {
|
||||
const mod = await loadModuleWithConfig(false)
|
||||
const t = mod.customUrlTransform
|
||||
|
||||
expect(t('#some-id')).toBe('#some-id')
|
||||
expect(t('//example.com/path')).toBe('//example.com/path')
|
||||
expect(t('relative/path/to/file')).toBe('relative/path/to/file')
|
||||
expect(t('/absolute/path')).toBe('/absolute/path')
|
||||
})
|
||||
|
||||
it('allows permitted schemes (http, https, mailto, xmpp, irc/ircs, abbr) case-insensitively', async () => {
|
||||
const mod = await loadModuleWithConfig(false)
|
||||
const t = mod.customUrlTransform
|
||||
|
||||
expect(t('http://example.com')).toBe('http://example.com')
|
||||
expect(t('HTTPS://example.com')).toBe('HTTPS://example.com')
|
||||
expect(t('mailto:user@example.com')).toBe('mailto:user@example.com')
|
||||
expect(t('xmpp:user@example.com')).toBe('xmpp:user@example.com')
|
||||
expect(t('irc:somewhere')).toBe('irc:somewhere')
|
||||
expect(t('ircs:secure')).toBe('ircs:secure')
|
||||
expect(t('abbr:some-ref')).toBe('abbr:some-ref')
|
||||
})
|
||||
|
||||
it('rejects unknown/unsafe schemes (javascript:, ftp:) and returns undefined', async () => {
|
||||
const mod = await loadModuleWithConfig(false)
|
||||
const t = mod.customUrlTransform
|
||||
|
||||
expect(t('javascript:alert(1)')).toBeUndefined()
|
||||
expect(t('ftp://example.com/file')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats colons inside path/query/fragment as NOT a scheme and returns the original URI', async () => {
|
||||
const mod = await loadModuleWithConfig(false)
|
||||
const t = mod.customUrlTransform
|
||||
|
||||
// colon after a slash -> part of path
|
||||
expect(t('folder/name:withcolon')).toBe('folder/name:withcolon')
|
||||
|
||||
// colon after question mark -> part of query
|
||||
expect(t('page?param:http')).toBe('page?param:http')
|
||||
|
||||
// colon after hash -> part of fragment
|
||||
expect(t('page#frag:with:colon')).toBe('page#frag:with:colon')
|
||||
})
|
||||
|
||||
it('respects ALLOW_UNSAFE_DATA_SCHEME: false blocks data:, true allows data:', async () => {
|
||||
const modFalse = await loadModuleWithConfig(false)
|
||||
expect(modFalse.customUrlTransform('data:text/plain;base64,SGVsbG8=')).toBeUndefined()
|
||||
|
||||
const modTrue = await loadModuleWithConfig(true)
|
||||
expect(modTrue.customUrlTransform('data:text/plain;base64,SGVsbG8=')).toBe('data:text/plain;base64,SGVsbG8=')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user