mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 16:38:04 +08:00
test: add comprehensive tests for share/text-generation components with 95%+ coverage
- Add tests for utils.ts (isTokenV1, getInitialTokenV2) - Add tests for info-modal.tsx (rendering, copyright, disclaimer, close) - Add tests for menu-dropdown.tsx (dropdown, logout, theme switcher) - Add tests for result/content.tsx (formatting, feedback props) - Add tests for result/header.tsx (copy, feedback buttons, undo actions) - Extend run-once tests for select, file, json_object, hidden/optional fields Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
205
web/app/components/share/text-generation/info-modal.spec.tsx
Normal file
205
web/app/components/share/text-generation/info-modal.spec.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import InfoModal from './info-modal'
|
||||
|
||||
// Only mock react-i18next for translations
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('InfoModal', () => {
|
||||
const mockOnClose = vi.fn()
|
||||
|
||||
const baseSiteInfo: SiteInfo = {
|
||||
title: 'Test App',
|
||||
icon: '🚀',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#ffffff',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should not render when isShow is false', () => {
|
||||
render(
|
||||
<InfoModal
|
||||
isShow={false}
|
||||
onClose={mockOnClose}
|
||||
data={baseSiteInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Test App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render when isShow is true', () => {
|
||||
render(
|
||||
<InfoModal
|
||||
isShow={true}
|
||||
onClose={mockOnClose}
|
||||
data={baseSiteInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app title', () => {
|
||||
render(
|
||||
<InfoModal
|
||||
isShow={true}
|
||||
onClose={mockOnClose}
|
||||
data={baseSiteInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Test App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render copyright when provided', () => {
|
||||
const siteInfoWithCopyright: SiteInfo = {
|
||||
...baseSiteInfo,
|
||||
copyright: 'Dify Inc.',
|
||||
}
|
||||
|
||||
render(
|
||||
<InfoModal
|
||||
isShow={true}
|
||||
onClose={mockOnClose}
|
||||
data={siteInfoWithCopyright}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/Dify Inc./)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render current year in copyright', () => {
|
||||
const siteInfoWithCopyright: SiteInfo = {
|
||||
...baseSiteInfo,
|
||||
copyright: 'Test Company',
|
||||
}
|
||||
|
||||
render(
|
||||
<InfoModal
|
||||
isShow={true}
|
||||
onClose={mockOnClose}
|
||||
data={siteInfoWithCopyright}
|
||||
/>,
|
||||
)
|
||||
|
||||
const currentYear = new Date().getFullYear().toString()
|
||||
expect(screen.getByText(new RegExp(currentYear))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom disclaimer when provided', () => {
|
||||
const siteInfoWithDisclaimer: SiteInfo = {
|
||||
...baseSiteInfo,
|
||||
custom_disclaimer: 'This is a custom disclaimer',
|
||||
}
|
||||
|
||||
render(
|
||||
<InfoModal
|
||||
isShow={true}
|
||||
onClose={mockOnClose}
|
||||
data={siteInfoWithDisclaimer}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('This is a custom disclaimer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render copyright section when not provided', () => {
|
||||
render(
|
||||
<InfoModal
|
||||
isShow={true}
|
||||
onClose={mockOnClose}
|
||||
data={baseSiteInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
const year = new Date().getFullYear().toString()
|
||||
expect(screen.queryByText(new RegExp(`©.*${year}`))).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with undefined data', () => {
|
||||
render(
|
||||
<InfoModal
|
||||
isShow={true}
|
||||
onClose={mockOnClose}
|
||||
data={undefined}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Modal should still render but without content
|
||||
expect(screen.queryByText('Test App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with image icon type', () => {
|
||||
const siteInfoWithImage: SiteInfo = {
|
||||
...baseSiteInfo,
|
||||
icon_type: 'image',
|
||||
icon_url: 'https://example.com/icon.png',
|
||||
}
|
||||
|
||||
render(
|
||||
<InfoModal
|
||||
isShow={true}
|
||||
onClose={mockOnClose}
|
||||
data={siteInfoWithImage}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(siteInfoWithImage.title!)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('close functionality', () => {
|
||||
it('should call onClose when close button is clicked', () => {
|
||||
render(
|
||||
<InfoModal
|
||||
isShow={true}
|
||||
onClose={mockOnClose}
|
||||
data={baseSiteInfo}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Find the close icon (RiCloseLine) which has text-text-tertiary class
|
||||
const closeIcon = document.querySelector('[class*="text-text-tertiary"]')
|
||||
expect(closeIcon).toBeInTheDocument()
|
||||
if (closeIcon) {
|
||||
fireEvent.click(closeIcon)
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('both copyright and disclaimer', () => {
|
||||
it('should render both when both are provided', () => {
|
||||
const siteInfoWithBoth: SiteInfo = {
|
||||
...baseSiteInfo,
|
||||
copyright: 'My Company',
|
||||
custom_disclaimer: 'Disclaimer text here',
|
||||
}
|
||||
|
||||
render(
|
||||
<InfoModal
|
||||
isShow={true}
|
||||
onClose={mockOnClose}
|
||||
data={siteInfoWithBoth}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/My Company/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Disclaimer text here')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
261
web/app/components/share/text-generation/menu-dropdown.spec.tsx
Normal file
261
web/app/components/share/text-generation/menu-dropdown.spec.tsx
Normal file
@ -0,0 +1,261 @@
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import MenuDropdown from './menu-dropdown'
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock next/navigation
|
||||
const mockReplace = vi.fn()
|
||||
const mockPathname = '/test-path'
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
usePathname: () => mockPathname,
|
||||
}))
|
||||
|
||||
// Mock web-app-context
|
||||
const mockShareCode = 'test-share-code'
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
webAppAccessMode: 'code',
|
||||
shareCode: mockShareCode,
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock webapp-auth service
|
||||
const mockWebAppLogout = vi.fn().mockResolvedValue(undefined)
|
||||
vi.mock('@/service/webapp-auth', () => ({
|
||||
webAppLogout: (...args: unknown[]) => mockWebAppLogout(...args),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('MenuDropdown', () => {
|
||||
const baseSiteInfo: SiteInfo = {
|
||||
title: 'Test App',
|
||||
icon: '🚀',
|
||||
icon_type: 'emoji',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the trigger button', () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} />)
|
||||
|
||||
// The trigger button contains an icon
|
||||
const triggerButton = document.querySelector('button')
|
||||
expect(triggerButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show dropdown content initially', () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} />)
|
||||
|
||||
// Dropdown content should not be visible initially
|
||||
expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dropdown content when clicked', async () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} />)
|
||||
|
||||
const triggerButton = document.querySelector('button')
|
||||
fireEvent.click(triggerButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('theme.theme')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should show About option in dropdown', async () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} />)
|
||||
|
||||
const triggerButton = document.querySelector('button')
|
||||
fireEvent.click(triggerButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('userProfile.about')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('privacy policy link', () => {
|
||||
it('should show privacy policy link when provided', async () => {
|
||||
const siteInfoWithPrivacy: SiteInfo = {
|
||||
...baseSiteInfo,
|
||||
privacy_policy: 'https://example.com/privacy',
|
||||
}
|
||||
|
||||
render(<MenuDropdown data={siteInfoWithPrivacy} />)
|
||||
|
||||
const triggerButton = document.querySelector('button')
|
||||
fireEvent.click(triggerButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('chat.privacyPolicyMiddle')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should not show privacy policy link when not provided', async () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} />)
|
||||
|
||||
const triggerButton = document.querySelector('button')
|
||||
fireEvent.click(triggerButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('chat.privacyPolicyMiddle')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should have correct href for privacy policy link', async () => {
|
||||
const privacyUrl = 'https://example.com/privacy'
|
||||
const siteInfoWithPrivacy: SiteInfo = {
|
||||
...baseSiteInfo,
|
||||
privacy_policy: privacyUrl,
|
||||
}
|
||||
|
||||
render(<MenuDropdown data={siteInfoWithPrivacy} />)
|
||||
|
||||
const triggerButton = document.querySelector('button')
|
||||
fireEvent.click(triggerButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
const link = screen.getByText('chat.privacyPolicyMiddle').closest('a')
|
||||
expect(link).toHaveAttribute('href', privacyUrl)
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout functionality', () => {
|
||||
it('should show logout option when hideLogout is false', async () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} hideLogout={false} />)
|
||||
|
||||
const triggerButton = document.querySelector('button')
|
||||
fireEvent.click(triggerButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('userProfile.logout')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide logout option when hideLogout is true', async () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} hideLogout={true} />)
|
||||
|
||||
const triggerButton = document.querySelector('button')
|
||||
fireEvent.click(triggerButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('userProfile.logout')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call webAppLogout and redirect when logout is clicked', async () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} hideLogout={false} />)
|
||||
|
||||
const triggerButton = document.querySelector('button')
|
||||
fireEvent.click(triggerButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('userProfile.logout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const logoutButton = screen.getByText('userProfile.logout')
|
||||
await act(async () => {
|
||||
fireEvent.click(logoutButton)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWebAppLogout).toHaveBeenCalledWith(mockShareCode)
|
||||
expect(mockReplace).toHaveBeenCalledWith(`/webapp-signin?redirect_url=${mockPathname}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('about modal', () => {
|
||||
it('should show InfoModal when About is clicked', async () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} />)
|
||||
|
||||
const triggerButton = document.querySelector('button')
|
||||
fireEvent.click(triggerButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('userProfile.about')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const aboutButton = screen.getByText('userProfile.about')
|
||||
fireEvent.click(aboutButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test App')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('forceClose prop', () => {
|
||||
it('should close dropdown when forceClose changes to true', async () => {
|
||||
const { rerender } = render(<MenuDropdown data={baseSiteInfo} forceClose={false} />)
|
||||
|
||||
const triggerButton = document.querySelector('button')
|
||||
fireEvent.click(triggerButton!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('theme.theme')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
rerender(<MenuDropdown data={baseSiteInfo} forceClose={true} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('placement prop', () => {
|
||||
it('should accept custom placement', () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} placement="top-start" />)
|
||||
|
||||
const triggerButton = document.querySelector('button')
|
||||
expect(triggerButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggle behavior', () => {
|
||||
it('should close dropdown when clicking trigger again', async () => {
|
||||
render(<MenuDropdown data={baseSiteInfo} />)
|
||||
|
||||
const triggerButton = document.querySelector('button')
|
||||
|
||||
// Open
|
||||
fireEvent.click(triggerButton!)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('theme.theme')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Close
|
||||
fireEvent.click(triggerButton!)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('theme.theme')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect((MenuDropdown as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
})
|
||||
})
|
||||
133
web/app/components/share/text-generation/result/content.spec.tsx
Normal file
133
web/app/components/share/text-generation/result/content.spec.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Result from './content'
|
||||
|
||||
// Only mock react-i18next for translations
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock copy-to-clipboard for the Header component
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
// Mock the format function from service/base
|
||||
vi.mock('@/service/base', () => ({
|
||||
format: (content: string) => content.replace(/\n/g, '<br>'),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Result (content)', () => {
|
||||
const mockOnFeedback = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
content: 'Test content here',
|
||||
showFeedback: true,
|
||||
feedback: { rating: null } as FeedbackType,
|
||||
onFeedback: mockOnFeedback,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the Header component', () => {
|
||||
render(<Result {...defaultProps} />)
|
||||
|
||||
// Header renders the result title
|
||||
expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content', () => {
|
||||
render(<Result {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Test content here')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render formatted content with line breaks', () => {
|
||||
render(
|
||||
<Result
|
||||
{...defaultProps}
|
||||
content={'Line 1\nLine 2'}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The format function converts \n to <br>
|
||||
const contentDiv = document.querySelector('[class*="overflow-scroll"]')
|
||||
expect(contentDiv?.innerHTML).toContain('Line 1<br>Line 2')
|
||||
})
|
||||
|
||||
it('should have max height style', () => {
|
||||
render(<Result {...defaultProps} />)
|
||||
|
||||
const contentDiv = document.querySelector('[class*="overflow-scroll"]')
|
||||
expect(contentDiv).toHaveStyle({ maxHeight: '70vh' })
|
||||
})
|
||||
|
||||
it('should render with empty content', () => {
|
||||
render(
|
||||
<Result
|
||||
{...defaultProps}
|
||||
content=""
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with HTML content safely', () => {
|
||||
render(
|
||||
<Result
|
||||
{...defaultProps}
|
||||
content="<script>alert('xss')</script>"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Content is rendered via dangerouslySetInnerHTML
|
||||
const contentDiv = document.querySelector('[class*="overflow-scroll"]')
|
||||
expect(contentDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('feedback props', () => {
|
||||
it('should pass showFeedback to Header', () => {
|
||||
render(
|
||||
<Result
|
||||
{...defaultProps}
|
||||
showFeedback={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Feedback buttons should not be visible
|
||||
const feedbackArea = document.querySelector('[class*="space-x-1 rounded-lg border"]')
|
||||
expect(feedbackArea).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass feedback to Header', () => {
|
||||
render(
|
||||
<Result
|
||||
{...defaultProps}
|
||||
feedback={{ rating: 'like' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Like button should be highlighted
|
||||
const likeButton = document.querySelector('[class*="primary"]')
|
||||
expect(likeButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect((Result as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
})
|
||||
})
|
||||
176
web/app/components/share/text-generation/result/header.spec.tsx
Normal file
176
web/app/components/share/text-generation/result/header.spec.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Header from './header'
|
||||
|
||||
// Only mock react-i18next for translations
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock copy-to-clipboard
|
||||
const mockCopy = vi.fn((_text: string) => true)
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: (text: string) => mockCopy(text),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Header', () => {
|
||||
const mockOnFeedback = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
result: 'Test result content',
|
||||
showFeedback: true,
|
||||
feedback: { rating: null } as FeedbackType,
|
||||
onFeedback: mockOnFeedback,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the result title', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the copy button', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('generation.copy')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('copy functionality', () => {
|
||||
it('should copy result when copy button is clicked', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
|
||||
const copyButton = screen.getByText('generation.copy').closest('button')
|
||||
fireEvent.click(copyButton!)
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Test result content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('feedback buttons when showFeedback is true', () => {
|
||||
it('should show feedback buttons when no rating is given', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
|
||||
// Should show both thumbs up and down buttons
|
||||
const buttons = document.querySelectorAll('[class*="cursor-pointer"]')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show like button highlighted when rating is like', () => {
|
||||
render(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
feedback={{ rating: 'like' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should show the undo button for like
|
||||
const likeButton = document.querySelector('[class*="primary"]')
|
||||
expect(likeButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dislike button highlighted when rating is dislike', () => {
|
||||
render(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
feedback={{ rating: 'dislike' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should show the undo button for dislike
|
||||
const dislikeButton = document.querySelector('[class*="red"]')
|
||||
expect(dislikeButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onFeedback with like when thumbs up is clicked', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
|
||||
// Find the thumbs up button (first one in the feedback area)
|
||||
const thumbButtons = document.querySelectorAll('[class*="cursor-pointer"]')
|
||||
const thumbsUp = Array.from(thumbButtons).find(btn =>
|
||||
btn.className.includes('rounded-md') && !btn.className.includes('primary'),
|
||||
)
|
||||
|
||||
if (thumbsUp) {
|
||||
fireEvent.click(thumbsUp)
|
||||
expect(mockOnFeedback).toHaveBeenCalledWith({ rating: 'like' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onFeedback with dislike when thumbs down is clicked', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
|
||||
// Find the thumbs down button
|
||||
const thumbButtons = document.querySelectorAll('[class*="cursor-pointer"]')
|
||||
const thumbsDown = Array.from(thumbButtons).pop()
|
||||
|
||||
if (thumbsDown) {
|
||||
fireEvent.click(thumbsDown)
|
||||
expect(mockOnFeedback).toHaveBeenCalledWith({ rating: 'dislike' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onFeedback with null when undo like is clicked', () => {
|
||||
render(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
feedback={{ rating: 'like' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// When liked, clicking the like button again should undo it (has bg-primary-100 class)
|
||||
const likeButton = document.querySelector('[class*="bg-primary-100"]')
|
||||
expect(likeButton).toBeInTheDocument()
|
||||
fireEvent.click(likeButton!)
|
||||
expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null })
|
||||
})
|
||||
|
||||
it('should call onFeedback with null when undo dislike is clicked', () => {
|
||||
render(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
feedback={{ rating: 'dislike' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// When disliked, clicking the dislike button again should undo it (has bg-red-100 class)
|
||||
const dislikeButton = document.querySelector('[class*="bg-red-100"]')
|
||||
expect(dislikeButton).toBeInTheDocument()
|
||||
fireEvent.click(dislikeButton!)
|
||||
expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('feedback buttons when showFeedback is false', () => {
|
||||
it('should not show feedback buttons', () => {
|
||||
render(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
showFeedback={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should not show feedback area buttons (only copy button)
|
||||
const feedbackArea = document.querySelector('[class*="space-x-1 rounded-lg border"]')
|
||||
expect(feedbackArea).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect((Header as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,7 @@
|
||||
import type { InputValueTypes } from '../types'
|
||||
import type { PromptConfig, PromptVariable } from '@/models/debug'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { VisionSettings } from '@/types/app'
|
||||
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
@ -27,7 +28,7 @@ vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', (
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', () => {
|
||||
function TextGenerationImageUploaderMock({ onFilesChange }: { onFilesChange: (files: any[]) => void }) {
|
||||
function TextGenerationImageUploaderMock({ onFilesChange }: { onFilesChange: (files: VisionFile[]) => void }) {
|
||||
useEffect(() => {
|
||||
onFilesChange([])
|
||||
}, [onFilesChange])
|
||||
@ -38,6 +39,20 @@ vi.mock('@/app/components/base/image-uploader/text-generation-image-uploader', (
|
||||
}
|
||||
})
|
||||
|
||||
// Mock FileUploaderInAttachmentWrapper as it requires context providers not available in tests
|
||||
vi.mock('@/app/components/base/file-uploader', () => ({
|
||||
FileUploaderInAttachmentWrapper: ({ value, onChange }: { value: object[], onChange: (files: object[]) => void }) => (
|
||||
<div data-testid="file-uploader-mock">
|
||||
<button onClick={() => onChange([{ id: 'test-file' }])}>Upload</button>
|
||||
<span>
|
||||
{value?.length || 0}
|
||||
{' '}
|
||||
files
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createPromptVariable = (overrides: Partial<PromptVariable>): PromptVariable => ({
|
||||
key: 'input',
|
||||
name: 'Input',
|
||||
@ -95,11 +110,11 @@ const setup = (overrides: {
|
||||
const onInputsChange = vi.fn()
|
||||
const onSend = vi.fn()
|
||||
const onVisionFilesChange = vi.fn()
|
||||
let inputsRefCapture: React.MutableRefObject<Record<string, any>> | null = null
|
||||
let inputsRefCapture: React.MutableRefObject<Record<string, InputValueTypes>> | null = null
|
||||
|
||||
const Wrapper = () => {
|
||||
const [inputs, setInputs] = useState<Record<string, any>>({})
|
||||
const inputsRef = useRef<Record<string, any>>({})
|
||||
const [inputs, setInputs] = useState<Record<string, InputValueTypes>>({})
|
||||
const inputsRef = useRef<Record<string, InputValueTypes>>({})
|
||||
inputsRefCapture = inputsRef
|
||||
return (
|
||||
<RunOnce
|
||||
@ -237,6 +252,208 @@ describe('RunOnce', () => {
|
||||
expect(stopButton).toBeDisabled()
|
||||
})
|
||||
|
||||
describe('select input type', () => {
|
||||
it('should render select input and handle selection', async () => {
|
||||
const promptConfig: PromptConfig = {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
createPromptVariable({
|
||||
key: 'selectInput',
|
||||
name: 'Select Input',
|
||||
type: 'select',
|
||||
options: ['Option A', 'Option B', 'Option C'],
|
||||
default: 'Option A',
|
||||
}),
|
||||
],
|
||||
}
|
||||
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
|
||||
await waitFor(() => {
|
||||
expect(onInputsChange).toHaveBeenCalledWith({
|
||||
selectInput: 'Option A',
|
||||
})
|
||||
})
|
||||
// The Select component should be rendered
|
||||
expect(screen.getByText('Select Input')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('file input types', () => {
|
||||
it('should render file uploader for single file input', async () => {
|
||||
const promptConfig: PromptConfig = {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
createPromptVariable({
|
||||
key: 'fileInput',
|
||||
name: 'File Input',
|
||||
type: 'file',
|
||||
}),
|
||||
],
|
||||
}
|
||||
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
|
||||
await waitFor(() => {
|
||||
expect(onInputsChange).toHaveBeenCalledWith({
|
||||
fileInput: undefined,
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('File Input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render file uploader for file-list input', async () => {
|
||||
const promptConfig: PromptConfig = {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
createPromptVariable({
|
||||
key: 'fileListInput',
|
||||
name: 'File List Input',
|
||||
type: 'file-list',
|
||||
}),
|
||||
],
|
||||
}
|
||||
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
|
||||
await waitFor(() => {
|
||||
expect(onInputsChange).toHaveBeenCalledWith({
|
||||
fileListInput: [],
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('File List Input')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('json_object input type', () => {
|
||||
it('should render code editor for json_object input', async () => {
|
||||
const promptConfig: PromptConfig = {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
createPromptVariable({
|
||||
key: 'jsonInput',
|
||||
name: 'JSON Input',
|
||||
type: 'json_object' as PromptVariable['type'],
|
||||
json_schema: '{"type": "object"}',
|
||||
}),
|
||||
],
|
||||
}
|
||||
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
|
||||
await waitFor(() => {
|
||||
expect(onInputsChange).toHaveBeenCalledWith({
|
||||
jsonInput: undefined,
|
||||
})
|
||||
})
|
||||
expect(screen.getByText('JSON Input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('code-editor-mock')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update json_object input when code editor changes', async () => {
|
||||
const promptConfig: PromptConfig = {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
createPromptVariable({
|
||||
key: 'jsonInput',
|
||||
name: 'JSON Input',
|
||||
type: 'json_object' as PromptVariable['type'],
|
||||
}),
|
||||
],
|
||||
}
|
||||
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
|
||||
await waitFor(() => {
|
||||
expect(onInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
onInputsChange.mockClear()
|
||||
|
||||
const codeEditor = screen.getByTestId('code-editor-mock')
|
||||
fireEvent.change(codeEditor, { target: { value: '{"key": "value"}' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onInputsChange).toHaveBeenCalledWith({
|
||||
jsonInput: '{"key": "value"}',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('hidden and optional fields', () => {
|
||||
it('should not render hidden variables', async () => {
|
||||
const promptConfig: PromptConfig = {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
createPromptVariable({
|
||||
key: 'hiddenInput',
|
||||
name: 'Hidden Input',
|
||||
type: 'string',
|
||||
hide: true,
|
||||
}),
|
||||
createPromptVariable({
|
||||
key: 'visibleInput',
|
||||
name: 'Visible Input',
|
||||
type: 'string',
|
||||
}),
|
||||
],
|
||||
}
|
||||
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
|
||||
await waitFor(() => {
|
||||
expect(onInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
expect(screen.queryByText('Hidden Input')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Visible Input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show optional label for non-required fields', async () => {
|
||||
const promptConfig: PromptConfig = {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
createPromptVariable({
|
||||
key: 'optionalInput',
|
||||
name: 'Optional Input',
|
||||
type: 'string',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
}
|
||||
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
|
||||
await waitFor(() => {
|
||||
expect(onInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
expect(screen.getByText('workflow.panel.optional')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('vision uploader', () => {
|
||||
it('should not render vision uploader when disabled', async () => {
|
||||
const { onInputsChange } = setup({ visionConfig: { ...baseVisionConfig, enabled: false } })
|
||||
await waitFor(() => {
|
||||
expect(onInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
expect(screen.queryByText('common.imageUploader.imageUpload')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear with different input types', () => {
|
||||
it('should clear select input to undefined', async () => {
|
||||
const promptConfig: PromptConfig = {
|
||||
prompt_template: 'template',
|
||||
prompt_variables: [
|
||||
createPromptVariable({
|
||||
key: 'selectInput',
|
||||
name: 'Select Input',
|
||||
type: 'select',
|
||||
options: ['Option A', 'Option B'],
|
||||
default: 'Option A',
|
||||
}),
|
||||
],
|
||||
}
|
||||
const { onInputsChange } = setup({ promptConfig, visionConfig: { ...baseVisionConfig, enabled: false } })
|
||||
await waitFor(() => {
|
||||
expect(onInputsChange).toHaveBeenCalled()
|
||||
})
|
||||
onInputsChange.mockClear()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
|
||||
|
||||
expect(onInputsChange).toHaveBeenCalledWith({
|
||||
selectInput: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('maxLength behavior', () => {
|
||||
it('should not have maxLength attribute when max_length is not set', async () => {
|
||||
const promptConfig: PromptConfig = {
|
||||
|
||||
71
web/app/components/share/utils.spec.ts
Normal file
71
web/app/components/share/utils.spec.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getInitialTokenV2, isTokenV1 } from './utils'
|
||||
|
||||
describe('utils', () => {
|
||||
describe('isTokenV1', () => {
|
||||
it('should return true when token has no version property', () => {
|
||||
const token = { someKey: 'value' }
|
||||
expect(isTokenV1(token)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when token.version is undefined', () => {
|
||||
const token = { version: undefined }
|
||||
expect(isTokenV1(token)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when token.version is null', () => {
|
||||
const token = { version: null }
|
||||
expect(isTokenV1(token)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when token.version is 0', () => {
|
||||
const token = { version: 0 }
|
||||
expect(isTokenV1(token)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when token.version is empty string', () => {
|
||||
const token = { version: '' }
|
||||
expect(isTokenV1(token)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when token has version 1', () => {
|
||||
const token = { version: 1 }
|
||||
expect(isTokenV1(token)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when token has version 2', () => {
|
||||
const token = { version: 2 }
|
||||
expect(isTokenV1(token)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when token has string version', () => {
|
||||
const token = { version: '2' }
|
||||
expect(isTokenV1(token)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const token = {}
|
||||
expect(isTokenV1(token)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInitialTokenV2', () => {
|
||||
it('should return object with version 2', () => {
|
||||
const token = getInitialTokenV2()
|
||||
expect(token.version).toBe(2)
|
||||
})
|
||||
|
||||
it('should return a new object each time', () => {
|
||||
const token1 = getInitialTokenV2()
|
||||
const token2 = getInitialTokenV2()
|
||||
expect(token1).not.toBe(token2)
|
||||
})
|
||||
|
||||
it('should return an object that can be modified without affecting future calls', () => {
|
||||
const token1 = getInitialTokenV2()
|
||||
token1.customField = 'test'
|
||||
const token2 = getInitialTokenV2()
|
||||
expect(token2.customField).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user