mirror of
https://github.com/langgenius/dify.git
synced 2026-03-21 06:18:27 +08:00
merge
This commit is contained in:
133
web/app/components/app/in-site-message/index.spec.tsx
Normal file
133
web/app/components/app/in-site-message/index.spec.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
/* eslint-disable e18e/prefer-static-regex */
|
||||
import type { InSiteMessageActionItem } from './index'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import InSiteMessage from './index'
|
||||
|
||||
describe('InSiteMessage', () => {
|
||||
const originalLocation = window.location
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('open', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: originalLocation,
|
||||
configurable: true,
|
||||
})
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const renderComponent = (actions: InSiteMessageActionItem[], props?: Partial<React.ComponentProps<typeof InSiteMessage>>) => {
|
||||
return render(
|
||||
<InSiteMessage
|
||||
title="Title\\nLine"
|
||||
subtitle="Subtitle\\nLine"
|
||||
main="Main content"
|
||||
actions={actions}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
// Validate baseline rendering and content normalization.
|
||||
describe('Rendering', () => {
|
||||
it('should render title, subtitle, markdown content, and action buttons', () => {
|
||||
const actions: InSiteMessageActionItem[] = [
|
||||
{ action: 'close', text: 'Close', type: 'default' },
|
||||
{ action: 'link', text: 'Learn more', type: 'primary', data: 'https://example.com' },
|
||||
]
|
||||
|
||||
renderComponent(actions, { className: 'custom-message' })
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'Close' })
|
||||
const learnMoreButton = screen.getByRole('button', { name: 'Learn more' })
|
||||
const panel = closeButton.closest('div.fixed')
|
||||
const titleElement = panel?.querySelector('.title-3xl-bold')
|
||||
const subtitleElement = panel?.querySelector('.body-md-regular')
|
||||
expect(panel).toHaveClass('custom-message')
|
||||
expect(titleElement).toHaveTextContent(/Title.*Line/s)
|
||||
expect(subtitleElement).toHaveTextContent(/Subtitle.*Line/s)
|
||||
expect(titleElement?.textContent).not.toContain('\\n')
|
||||
expect(subtitleElement?.textContent).not.toContain('\\n')
|
||||
expect(screen.getByText('Main content')).toBeInTheDocument()
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
expect(learnMoreButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fallback to default header background when headerBgUrl is empty string', () => {
|
||||
const actions: InSiteMessageActionItem[] = [{ action: 'close', text: 'Close', type: 'default' }]
|
||||
|
||||
const { container } = renderComponent(actions, { headerBgUrl: '' })
|
||||
const header = container.querySelector('div[style]')
|
||||
expect(header).toHaveStyle({ backgroundImage: 'url(/in-site-message/header-bg.svg)' })
|
||||
})
|
||||
})
|
||||
|
||||
// Validate action handling for close and link actions.
|
||||
describe('Actions', () => {
|
||||
it('should call onAction and hide component when close action is clicked', () => {
|
||||
const onAction = vi.fn()
|
||||
const closeAction: InSiteMessageActionItem = { action: 'close', text: 'Close', type: 'default' }
|
||||
|
||||
renderComponent([closeAction], { onAction })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
expect(onAction).toHaveBeenCalledWith(closeAction)
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open a new tab when link action data is a string', () => {
|
||||
const linkAction: InSiteMessageActionItem = {
|
||||
action: 'link',
|
||||
text: 'Open link',
|
||||
type: 'primary',
|
||||
data: 'https://example.com',
|
||||
}
|
||||
|
||||
renderComponent([linkAction])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open link' }))
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer')
|
||||
})
|
||||
|
||||
it('should navigate with location.assign when link action target is _self', () => {
|
||||
const assignSpy = vi.fn()
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
...originalLocation,
|
||||
assign: assignSpy,
|
||||
},
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
const linkAction: InSiteMessageActionItem = {
|
||||
action: 'link',
|
||||
text: 'Open self',
|
||||
type: 'primary',
|
||||
data: { href: 'https://example.com/self', target: '_self' },
|
||||
}
|
||||
|
||||
renderComponent([linkAction])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open self' }))
|
||||
|
||||
expect(assignSpy).toHaveBeenCalledWith('https://example.com/self')
|
||||
expect(window.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not trigger navigation when link data is invalid', () => {
|
||||
const linkAction: InSiteMessageActionItem = {
|
||||
action: 'link',
|
||||
text: 'Broken link',
|
||||
type: 'primary',
|
||||
data: { rel: 'noopener' },
|
||||
}
|
||||
|
||||
renderComponent([linkAction])
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Broken link' }))
|
||||
|
||||
expect(window.open).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -25,9 +25,10 @@ type InSiteMessageProps = {
|
||||
title: string
|
||||
}
|
||||
|
||||
const breakLineRegex = /\\n/g
|
||||
const LINE_BREAK_REGEX = /\\n/g
|
||||
|
||||
function normalizeLineBreaks(text: string): string {
|
||||
return text.replace(breakLineRegex, '\n')
|
||||
return text.replace(LINE_BREAK_REGEX, '\n')
|
||||
}
|
||||
|
||||
function normalizeLinkData(data: unknown): { href: string, rel?: string, target?: string } | null {
|
||||
@ -109,7 +110,7 @@ function InSiteMessage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-2 pt-4 text-text-secondary body-md-regular [&_p]:mb-2 [&_ul]:list-disc [&_ul]:pl-5">
|
||||
<div className="px-4 pb-2 pt-4 text-text-secondary body-md-regular">
|
||||
<MarkdownWithDirective markdown={main} />
|
||||
</div>
|
||||
|
||||
|
||||
202
web/app/components/app/in-site-message/notification.spec.tsx
Normal file
202
web/app/components/app/in-site-message/notification.spec.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import InSiteMessageNotification from './notification'
|
||||
|
||||
const {
|
||||
mockConfig,
|
||||
mockNotification,
|
||||
mockNotificationDismiss,
|
||||
} = vi.hoisted(() => ({
|
||||
mockConfig: {
|
||||
isCloudEdition: true,
|
||||
},
|
||||
mockNotification: vi.fn(),
|
||||
mockNotificationDismiss: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.isCloudEdition
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
notification: (...args: unknown[]) => mockNotification(...args),
|
||||
notificationDismiss: (...args: unknown[]) => mockNotificationDismiss(...args),
|
||||
},
|
||||
consoleQuery: {
|
||||
notification: {
|
||||
queryKey: () => ['console', 'notification'],
|
||||
},
|
||||
notificationDismiss: {
|
||||
mutationKey: () => ['console', 'notificationDismiss'],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
return Wrapper
|
||||
}
|
||||
|
||||
describe('InSiteMessageNotification', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfig.isCloudEdition = true
|
||||
vi.stubGlobal('open', vi.fn())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Validate query gating and empty state rendering.
|
||||
describe('Rendering', () => {
|
||||
it('should render null and skip query when not cloud edition', async () => {
|
||||
mockConfig.isCloudEdition = false
|
||||
const Wrapper = createWrapper()
|
||||
const { container } = render(<InSiteMessageNotification />, { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('should render null when notification list is empty', async () => {
|
||||
mockNotification.mockResolvedValue({ notifications: [] })
|
||||
const Wrapper = createWrapper()
|
||||
const { container } = render(<InSiteMessageNotification />, { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotification).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
|
||||
// Validate parsed-body behavior and action handling.
|
||||
describe('Notification body parsing and actions', () => {
|
||||
it('should render parsed main/actions and dismiss only on close action', async () => {
|
||||
mockNotification.mockResolvedValue({
|
||||
notifications: [
|
||||
{
|
||||
notification_id: 'n-1',
|
||||
title: 'Update title',
|
||||
subtitle: 'Update subtitle',
|
||||
title_pic_url: 'https://example.com/bg.png',
|
||||
body: JSON.stringify({
|
||||
main: 'Parsed body main',
|
||||
actions: [
|
||||
{ action: 'link', data: 'https://example.com/docs', text: 'Visit docs', type: 'primary' },
|
||||
{ action: 'close', text: 'Dismiss now', type: 'default' },
|
||||
{ action: 'link', data: 'https://example.com/invalid', text: 100, type: 'primary' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
mockNotificationDismiss.mockResolvedValue({ success: true })
|
||||
|
||||
const Wrapper = createWrapper()
|
||||
render(<InSiteMessageNotification />, { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Parsed body main')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Visit docs' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Dismiss now' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Invalid' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Visit docs' }))
|
||||
expect(mockNotificationDismiss).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss now' }))
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationDismiss).toHaveBeenCalledWith({
|
||||
body: {
|
||||
notification_id: 'n-1',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should fallback to raw body and default close action when body is invalid json', async () => {
|
||||
mockNotification.mockResolvedValue({
|
||||
notifications: [
|
||||
{
|
||||
notification_id: 'n-2',
|
||||
title: 'Fallback title',
|
||||
subtitle: 'Fallback subtitle',
|
||||
title_pic_url: 'https://example.com/bg-2.png',
|
||||
body: 'raw body text',
|
||||
},
|
||||
],
|
||||
})
|
||||
mockNotificationDismiss.mockResolvedValue({ success: true })
|
||||
|
||||
const Wrapper = createWrapper()
|
||||
render(<InSiteMessageNotification />, { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('raw body text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: 'common.operation.close' })
|
||||
fireEvent.click(closeButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotificationDismiss).toHaveBeenCalledWith({
|
||||
body: {
|
||||
notification_id: 'n-2',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should fallback to default close action when parsed actions are all invalid', async () => {
|
||||
mockNotification.mockResolvedValue({
|
||||
notifications: [
|
||||
{
|
||||
notification_id: 'n-3',
|
||||
title: 'Invalid action title',
|
||||
subtitle: 'Invalid action subtitle',
|
||||
title_pic_url: 'https://example.com/bg-3.png',
|
||||
body: JSON.stringify({
|
||||
main: 'Main from parsed body',
|
||||
actions: [
|
||||
{ action: 'link', type: 'primary', text: 100, data: 'https://example.com' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const Wrapper = createWrapper()
|
||||
render(<InSiteMessageNotification />, { wrapper: Wrapper })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Main from parsed body')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -65,7 +65,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
|
||||
if (primarySrc) {
|
||||
// Delayed generation of waveform data
|
||||
// eslint-disable-next-line ts/no-use-before-define
|
||||
const timer = setTimeout(() => generateWaveformData(primarySrc), 1000)
|
||||
const timer = setTimeout(generateWaveformData, 1000, primarySrc)
|
||||
return () => {
|
||||
audio.removeEventListener('loadedmetadata', setAudioData)
|
||||
audio.removeEventListener('timeupdate', setAudioTime)
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
import { validateDirectiveProps } from './markdown-with-directive-schema'
|
||||
|
||||
describe('markdown-with-directive-schema', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Validate the happy path for known directives.
|
||||
describe('valid props', () => {
|
||||
it('should return true for withiconcardlist when className is provided', () => {
|
||||
expect(validateDirectiveProps('withiconcardlist', { className: 'custom-list' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true for withiconcarditem when icon is https URL', () => {
|
||||
expect(validateDirectiveProps('withiconcarditem', { icon: 'https://example.com/icon.png' })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Validate strict schema constraints and error branches.
|
||||
describe('invalid props', () => {
|
||||
it('should return false and log error for unknown directive name', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const isValid = validateDirectiveProps('unknown-directive', { className: 'custom-list' })
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[markdown-with-directive] Unknown directive name.',
|
||||
expect.objectContaining({
|
||||
attributes: { className: 'custom-list' },
|
||||
directive: 'unknown-directive',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should return false and log error for non-http icon URL', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const isValid = validateDirectiveProps('withiconcarditem', { icon: 'ftp://example.com/icon.png' })
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[markdown-with-directive] Invalid directive props.',
|
||||
expect.objectContaining({
|
||||
attributes: { icon: 'ftp://example.com/icon.png' },
|
||||
directive: 'withiconcarditem',
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: 'icon',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should return false when extra field is provided to strict list schema', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const isValid = validateDirectiveProps('withiconcardlist', {
|
||||
className: 'custom-list',
|
||||
extra: 'not-allowed',
|
||||
})
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[markdown-with-directive] Invalid directive props.',
|
||||
expect.objectContaining({
|
||||
directive: 'withiconcardlist',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -5,11 +5,12 @@ const commonSchema = {
|
||||
}
|
||||
export const withIconCardListPropsSchema = z.object(commonSchema).strict()
|
||||
|
||||
const startWithHttpOrHttps = /^https?:\/\//i
|
||||
const HTTP_URL_REGEX = /^https?:\/\//i
|
||||
|
||||
export const withIconCardItemPropsSchema = z.object({
|
||||
...commonSchema,
|
||||
icon: z.string().trim().url().refine(
|
||||
value => startWithHttpOrHttps.test(value),
|
||||
value => HTTP_URL_REGEX.test(value),
|
||||
'icon must be a http/https URL',
|
||||
),
|
||||
}).strict()
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import WithIconCardItem from './with-icon-card-item'
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: ({ unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('WithIconCardItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verify icon image and content rendering.
|
||||
describe('rendering', () => {
|
||||
it('should render icon image and children content', () => {
|
||||
render(
|
||||
<WithIconCardItem icon="https://example.com/icon.png">
|
||||
<span>Card item content</span>
|
||||
</WithIconCardItem>,
|
||||
)
|
||||
|
||||
const icon = screen.getByAltText('icon')
|
||||
expect(icon).toBeInTheDocument()
|
||||
expect(icon).toHaveAttribute('src', 'https://example.com/icon.png')
|
||||
expect(icon).toHaveClass('object-contain')
|
||||
expect(screen.getByText('Card item content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,34 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import WithIconCardList from './with-icon-card-list'
|
||||
|
||||
describe('WithIconCardList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verify baseline rendering and className merge behavior.
|
||||
describe('rendering', () => {
|
||||
it('should render children and merge custom className with base class', () => {
|
||||
const { container } = render(
|
||||
<WithIconCardList className="custom-list-class">
|
||||
<span>List child</span>
|
||||
</WithIconCardList>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('List child')).toBeInTheDocument()
|
||||
expect(container.firstElementChild).toHaveClass('space-y-1')
|
||||
expect(container.firstElementChild).toHaveClass('custom-list-class')
|
||||
})
|
||||
|
||||
it('should keep base class when className is not provided', () => {
|
||||
const { container } = render(
|
||||
<WithIconCardList>
|
||||
<span>Only base class</span>
|
||||
</WithIconCardList>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Only base class')).toBeInTheDocument()
|
||||
expect(container.firstElementChild).toHaveClass('space-y-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
196
web/app/components/base/markdown-with-directive/index.spec.tsx
Normal file
196
web/app/components/base/markdown-with-directive/index.spec.tsx
Normal file
@ -0,0 +1,196 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { validateDirectiveProps } from './components/markdown-with-directive-schema'
|
||||
import WithIconCardItem from './components/with-icon-card-item'
|
||||
import WithIconCardList from './components/with-icon-card-list'
|
||||
import { MarkdownWithDirective } from './index'
|
||||
|
||||
const FOUR_COLON_RE = /:{4}/
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
|
||||
}))
|
||||
|
||||
describe('markdown-with-directive', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Validate directive prop schemas and error paths.
|
||||
describe('Directive schema validation', () => {
|
||||
it('should return true when withiconcardlist props are valid', () => {
|
||||
expect(validateDirectiveProps('withiconcardlist', { className: 'custom-list' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when withiconcarditem props are valid', () => {
|
||||
expect(validateDirectiveProps('withiconcarditem', { icon: 'https://example.com/icon.png' })).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false and log when directive name is unknown', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const isValid = validateDirectiveProps('unknown-directive', { className: 'custom-list' })
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[markdown-with-directive] Unknown directive name.',
|
||||
expect.objectContaining({
|
||||
attributes: { className: 'custom-list' },
|
||||
directive: 'unknown-directive',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should return false and log when withiconcarditem icon is not http/https', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const isValid = validateDirectiveProps('withiconcarditem', { icon: 'ftp://example.com/icon.png' })
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[markdown-with-directive] Invalid directive props.',
|
||||
expect.objectContaining({
|
||||
attributes: { icon: 'ftp://example.com/icon.png' },
|
||||
directive: 'withiconcarditem',
|
||||
issues: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: 'icon',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should return false when extra props are provided to strict schema', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const isValid = validateDirectiveProps('withiconcardlist', {
|
||||
className: 'custom-list',
|
||||
extra: 'not-allowed',
|
||||
})
|
||||
|
||||
expect(isValid).toBe(false)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[markdown-with-directive] Invalid directive props.',
|
||||
expect.objectContaining({
|
||||
directive: 'withiconcardlist',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Validate WithIconCardList rendering and class merge behavior.
|
||||
describe('WithIconCardList component', () => {
|
||||
it('should render children and merge className with base class', () => {
|
||||
const { container } = render(
|
||||
<WithIconCardList className="custom-list-class">
|
||||
<span>List child</span>
|
||||
</WithIconCardList>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('List child')).toBeInTheDocument()
|
||||
expect(container.firstElementChild).toHaveClass('space-y-1')
|
||||
expect(container.firstElementChild).toHaveClass('custom-list-class')
|
||||
})
|
||||
|
||||
it('should render base class when className is not provided', () => {
|
||||
const { container } = render(
|
||||
<WithIconCardList>
|
||||
<span>Only base class</span>
|
||||
</WithIconCardList>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Only base class')).toBeInTheDocument()
|
||||
expect(container.firstElementChild).toHaveClass('space-y-1')
|
||||
})
|
||||
})
|
||||
|
||||
// Validate WithIconCardItem rendering and image prop forwarding.
|
||||
describe('WithIconCardItem component', () => {
|
||||
it('should render icon image and child content', () => {
|
||||
render(
|
||||
<WithIconCardItem icon="https://example.com/icon.png">
|
||||
<span>Card item content</span>
|
||||
</WithIconCardItem>,
|
||||
)
|
||||
|
||||
const icon = screen.getByAltText('icon')
|
||||
expect(icon).toBeInTheDocument()
|
||||
expect(icon).toHaveAttribute('src', 'https://example.com/icon.png')
|
||||
expect(screen.getByText('Card item content')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Validate markdown parsing pipeline, sanitizer usage, and invalid fallback.
|
||||
describe('MarkdownWithDirective component', () => {
|
||||
it('should render directives when markdown is valid', () => {
|
||||
const markdown = [
|
||||
'::withiconcardlist {className="custom-list"}',
|
||||
':withiconcarditem[Card Title] {icon="https://example.com/icon.png"} {className="custom-item"}',
|
||||
'::',
|
||||
].join('\n')
|
||||
|
||||
const { container } = render(<MarkdownWithDirective markdown={markdown} />)
|
||||
|
||||
const list = container.querySelector('.custom-list')
|
||||
expect(list).toBeInTheDocument()
|
||||
expect(list).toHaveClass('space-y-1')
|
||||
expect(screen.getByText('Card Title')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('icon')).toHaveAttribute('src', 'https://example.com/icon.png')
|
||||
})
|
||||
|
||||
it('should replace output with invalid content when directive is unknown', () => {
|
||||
const markdown = ':unknown[Bad Content]{foo="bar"}'
|
||||
|
||||
render(<MarkdownWithDirective markdown={markdown} />)
|
||||
|
||||
expect(screen.getByText('invalid content')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Bad Content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should replace output with invalid content when directive props are invalid', () => {
|
||||
const markdown = ':withiconcarditem[Invalid Icon]{icon="not-a-url"}'
|
||||
|
||||
render(<MarkdownWithDirective markdown={markdown} />)
|
||||
|
||||
expect(screen.getByText('invalid content')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Invalid Icon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render trailing fence text for four-colon container directives', () => {
|
||||
const markdown = [
|
||||
'::::withiconcardlist {className="custom-list"}',
|
||||
':withiconcarditem[Card Title]{icon="https://example.com/icon.png"}',
|
||||
'::::',
|
||||
].join('\n')
|
||||
|
||||
const { container } = render(<MarkdownWithDirective markdown={markdown} />)
|
||||
|
||||
expect(screen.getByText('Card Title')).toBeInTheDocument()
|
||||
expect(screen.queryByText(FOUR_COLON_RE)).not.toBeInTheDocument()
|
||||
expect(container.textContent).not.toContain('::::')
|
||||
})
|
||||
|
||||
it('should call sanitizer and render based on sanitized markdown', () => {
|
||||
const sanitizeSpy = vi.spyOn(DOMPurify, 'sanitize')
|
||||
.mockReturnValue(':withiconcarditem[Sanitized]{icon="https://example.com/safe.png"}')
|
||||
|
||||
render(<MarkdownWithDirective markdown="<script>alert(1)</script>" />)
|
||||
|
||||
expect(sanitizeSpy).toHaveBeenCalledWith('<script>alert(1)</script>', {
|
||||
ALLOWED_ATTR: [],
|
||||
ALLOWED_TAGS: [],
|
||||
})
|
||||
expect(screen.getByText('Sanitized')).toBeInTheDocument()
|
||||
expect(screen.getByAltText('icon')).toHaveAttribute('src', 'https://example.com/safe.png')
|
||||
})
|
||||
|
||||
it('should render empty output and skip sanitizer when markdown is empty', () => {
|
||||
const sanitizeSpy = vi.spyOn(DOMPurify, 'sanitize')
|
||||
const { container } = render(<MarkdownWithDirective markdown="" />)
|
||||
|
||||
expect(sanitizeSpy).not.toHaveBeenCalled()
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,12 +1,37 @@
|
||||
import type { Components } from 'react-markdown'
|
||||
import type { Components, StreamdownProps } from 'streamdown'
|
||||
import DOMPurify from 'dompurify'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkDirective from 'remark-directive'
|
||||
import { defaultRehypePlugins, Streamdown } from 'streamdown'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { validateDirectiveProps } from './components/markdown-with-directive-schema'
|
||||
import WithIconCardItem from './components/with-icon-card-item'
|
||||
import WithIconCardList from './components/with-icon-card-list'
|
||||
|
||||
// Adapter to map generic props to WithIconListProps
|
||||
function WithIconCardListAdapter(props: Record<string, unknown>) {
|
||||
// Extract expected props, fallback to undefined if not present
|
||||
const { children, className } = props
|
||||
return (
|
||||
<WithIconCardList
|
||||
children={children as React.ReactNode}
|
||||
className={typeof className === 'string' ? className : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Adapter to map generic props to WithIconCardItemProps
|
||||
function WithIconCardItemAdapter(props: Record<string, unknown>) {
|
||||
const { icon, className, children } = props
|
||||
return (
|
||||
<WithIconCardItem
|
||||
icon={typeof icon === 'string' ? icon : ''}
|
||||
className={typeof className === 'string' ? className : undefined}
|
||||
>
|
||||
{children as React.ReactNode}
|
||||
</WithIconCardItem>
|
||||
)
|
||||
}
|
||||
|
||||
type DirectiveNode = {
|
||||
type?: string
|
||||
name?: string
|
||||
@ -34,20 +59,68 @@ function isMdastRoot(node: Parameters<typeof visit>[0]): node is MdastRoot {
|
||||
return candidate.type === 'root' && Array.isArray(candidate.children)
|
||||
}
|
||||
|
||||
const lineReg = /^(\s*:+[a-z][\w-]*(?:\[[^\]\n]*\])?)\s+((?:\{[^}\n]*\}\s*)+)$/i
|
||||
const attrReg = /\{([^}\n]*)\}/g
|
||||
// Move the regex to module scope to avoid recompilation
|
||||
const DIRECTIVE_ATTRIBUTE_BLOCK_REGEX = /^(\s*:+[a-z][\w-]*(?:\[[^\]\n]*\])?)\s+((?:\{[^}\n]*\}\s*)+)$/i
|
||||
const ATTRIBUTE_BLOCK_REGEX = /\{([^}\n]*)\}/g
|
||||
type PluggableList = NonNullable<StreamdownProps['rehypePlugins']>
|
||||
type Pluggable = PluggableList[number]
|
||||
type AttributeDefinition = string | [string, ...(string | boolean | RegExp)[]]
|
||||
type SanitizeSchema = {
|
||||
tagNames?: string[]
|
||||
attributes?: Record<string, AttributeDefinition[]>
|
||||
required?: Record<string, Record<string, unknown>>
|
||||
clobber?: string[]
|
||||
clobberPrefix?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const DIRECTIVE_ALLOWED_TAGS: Record<string, AttributeDefinition[]> = {
|
||||
withiconcardlist: ['className'],
|
||||
withiconcarditem: ['icon', 'className'],
|
||||
}
|
||||
|
||||
function buildDirectiveRehypePlugins(): PluggableList {
|
||||
const [sanitizePlugin, defaultSanitizeSchema]
|
||||
= defaultRehypePlugins.sanitize as [Pluggable, SanitizeSchema]
|
||||
|
||||
const tagNames = new Set([
|
||||
...(defaultSanitizeSchema.tagNames ?? []),
|
||||
...Object.keys(DIRECTIVE_ALLOWED_TAGS),
|
||||
])
|
||||
|
||||
const attributes: Record<string, AttributeDefinition[]> = {
|
||||
...(defaultSanitizeSchema.attributes ?? {}),
|
||||
}
|
||||
|
||||
for (const [tagName, allowedAttributes] of Object.entries(DIRECTIVE_ALLOWED_TAGS))
|
||||
attributes[tagName] = [...(attributes[tagName] ?? []), ...allowedAttributes]
|
||||
|
||||
const sanitizeSchema: SanitizeSchema = {
|
||||
...defaultSanitizeSchema,
|
||||
tagNames: [...tagNames],
|
||||
attributes,
|
||||
}
|
||||
|
||||
return [
|
||||
defaultRehypePlugins.raw,
|
||||
[sanitizePlugin, sanitizeSchema] as Pluggable,
|
||||
defaultRehypePlugins.harden,
|
||||
]
|
||||
}
|
||||
|
||||
const directiveRehypePlugins = buildDirectiveRehypePlugins()
|
||||
|
||||
function normalizeDirectiveAttributeBlocks(markdown: string): string {
|
||||
const lines = markdown.split('\n')
|
||||
|
||||
return lines.map((line) => {
|
||||
const match = line.match(lineReg)
|
||||
const match = line.match(DIRECTIVE_ATTRIBUTE_BLOCK_REGEX)
|
||||
if (!match)
|
||||
return line
|
||||
|
||||
const directivePrefix = match[1]
|
||||
const attributeBlocks = match[2]
|
||||
const attrMatches = [...attributeBlocks.matchAll(attrReg)]
|
||||
const attrMatches = [...attributeBlocks.matchAll(ATTRIBUTE_BLOCK_REGEX)]
|
||||
if (attrMatches.length === 0)
|
||||
return line
|
||||
|
||||
@ -102,7 +175,8 @@ function isValidDirectiveAst(tree: Parameters<typeof visit>[0]): boolean {
|
||||
return isValid
|
||||
}
|
||||
|
||||
const invalidTextReg = /^\s*:{2,}[a-z][\w-]*/im
|
||||
const UNPARSED_DIRECTIVE_LIKE_TEXT_REGEX = /^\s*:{2,}[a-z][\w-]*/im
|
||||
|
||||
function hasUnparsedDirectiveLikeText(tree: Parameters<typeof visit>[0]): boolean {
|
||||
let hasInvalidText = false
|
||||
|
||||
@ -112,7 +186,7 @@ function hasUnparsedDirectiveLikeText(tree: Parameters<typeof visit>[0]): boolea
|
||||
|
||||
const textNode = node as { value?: string }
|
||||
const value = textNode.value || ''
|
||||
if (invalidTextReg.test(value))
|
||||
if (UNPARSED_DIRECTIVE_LIKE_TEXT_REGEX.test(value))
|
||||
hasInvalidText = true
|
||||
})
|
||||
|
||||
@ -166,9 +240,9 @@ function directivePlugin() {
|
||||
}
|
||||
|
||||
const directiveComponents = {
|
||||
withiconcardlist: WithIconCardList,
|
||||
withiconcarditem: WithIconCardItem,
|
||||
} as unknown as Components
|
||||
withiconcardlist: WithIconCardListAdapter,
|
||||
withiconcarditem: WithIconCardItemAdapter,
|
||||
} satisfies Components
|
||||
|
||||
type MarkdownWithDirectiveProps = {
|
||||
markdown: string
|
||||
@ -192,13 +266,20 @@ export function MarkdownWithDirective({ markdown }: MarkdownWithDirectiveProps)
|
||||
const sanitizedMarkdown = sanitizeMarkdownInput(markdown)
|
||||
const normalizedMarkdown = normalizeDirectiveAttributeBlocks(sanitizedMarkdown)
|
||||
|
||||
if (!normalizedMarkdown)
|
||||
return null
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
skipHtml
|
||||
remarkPlugins={[remarkDirective, directivePlugin]}
|
||||
components={directiveComponents}
|
||||
>
|
||||
{normalizedMarkdown}
|
||||
</ReactMarkdown>
|
||||
<div className="markdown-body">
|
||||
<Streamdown
|
||||
mode="static"
|
||||
remarkPlugins={[remarkDirective, directivePlugin]}
|
||||
rehypePlugins={directiveRehypePlugins}
|
||||
components={directiveComponents}
|
||||
>
|
||||
{normalizedMarkdown}
|
||||
</Streamdown>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@ -26,8 +26,8 @@ type CapturedProps = {
|
||||
|
||||
const getLastWrapperProps = (): CapturedProps => {
|
||||
const calls = mockReactMarkdownWrapper.mock.calls
|
||||
const lastCall = calls[calls.length - 1]
|
||||
return lastCall[0] as CapturedProps
|
||||
const lastCall = calls.at(-1)
|
||||
return lastCall?.[0] as CapturedProps
|
||||
}
|
||||
|
||||
describe('Markdown', () => {
|
||||
|
||||
@ -151,8 +151,6 @@
|
||||
"reactflow": "11.11.4",
|
||||
"remark-breaks": "4.0.0",
|
||||
"remark-directive": "4.0.0",
|
||||
"remark-gfm": "4.0.1",
|
||||
"remark-math": "6.0.0",
|
||||
"scheduler": "0.27.0",
|
||||
"semver": "7.7.4",
|
||||
"sharp": "0.34.5",
|
||||
|
||||
756
web/pnpm-lock.yaml
generated
756
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user