mirror of
https://github.com/langgenius/dify.git
synced 2026-01-08 07:14:14 +00:00
- Introduced new test files for Processing and EmbeddingProcess components, covering rendering, props variations, edge cases, and memoization. - Added mock implementations for external dependencies to facilitate testing. - Enhanced Jest configuration to handle module resolution for lodash and ky. - Created a mock for the ky HTTP client to avoid ESM issues in Jest tests. - Updated Processing component to handle optional chaining for retrieval method. This commit significantly improves test coverage and stability for the document processing features.
569 lines
19 KiB
TypeScript
569 lines
19 KiB
TypeScript
import React from 'react'
|
|
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
import type { UsagePlanInfo } from '@/app/components/billing/type'
|
|
import { Plan } from '@/app/components/billing/type'
|
|
import { createMockPlan, createMockPlanTotal, createMockPlanUsage } from '@/__mocks__/provider-context'
|
|
import { AppModeEnum } from '@/types/app'
|
|
import CreateAppModal from './index'
|
|
import type { CreateAppModalProps } from './index'
|
|
|
|
let mockTranslationOverrides: Record<string, string | undefined> = {}
|
|
|
|
jest.mock('react-i18next', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, options?: Record<string, unknown>) => {
|
|
const override = mockTranslationOverrides[key]
|
|
if (override !== undefined)
|
|
return override
|
|
if (options?.returnObjects)
|
|
return [`${key}-feature-1`, `${key}-feature-2`]
|
|
if (options)
|
|
return `${key}:${JSON.stringify(options)}`
|
|
return key
|
|
},
|
|
i18n: {
|
|
language: 'en',
|
|
changeLanguage: jest.fn(),
|
|
},
|
|
}),
|
|
Trans: ({ children }: { children?: React.ReactNode }) => children,
|
|
initReactI18next: {
|
|
type: '3rdParty',
|
|
init: jest.fn(),
|
|
},
|
|
}))
|
|
|
|
// Avoid heavy emoji dataset initialization during unit tests.
|
|
jest.mock('emoji-mart', () => ({
|
|
init: jest.fn(),
|
|
SearchIndex: { search: jest.fn().mockResolvedValue([]) },
|
|
}))
|
|
jest.mock('@emoji-mart/data', () => ({
|
|
__esModule: true,
|
|
default: {
|
|
categories: [
|
|
{ id: 'people', emojis: ['😀'] },
|
|
],
|
|
},
|
|
}))
|
|
|
|
jest.mock('next/navigation', () => ({
|
|
useParams: () => ({}),
|
|
}))
|
|
|
|
jest.mock('@/context/app-context', () => ({
|
|
useAppContext: () => ({
|
|
userProfile: { email: 'test@example.com' },
|
|
langGeniusVersionInfo: { current_version: '0.0.0' },
|
|
}),
|
|
}))
|
|
|
|
const createPlanInfo = (buildApps: number): UsagePlanInfo => ({
|
|
vectorSpace: 0,
|
|
buildApps,
|
|
teamMembers: 0,
|
|
annotatedResponse: 0,
|
|
documentsUploadQuota: 0,
|
|
apiRateLimit: 0,
|
|
triggerEvents: 0,
|
|
})
|
|
|
|
let mockEnableBilling = false
|
|
let mockPlanType: Plan = Plan.team
|
|
let mockUsagePlanInfo: UsagePlanInfo = createPlanInfo(1)
|
|
let mockTotalPlanInfo: UsagePlanInfo = createPlanInfo(10)
|
|
|
|
jest.mock('@/context/provider-context', () => ({
|
|
useProviderContext: () => {
|
|
const withPlan = createMockPlan(mockPlanType)
|
|
const withUsage = createMockPlanUsage(mockUsagePlanInfo, withPlan)
|
|
const withTotal = createMockPlanTotal(mockTotalPlanInfo, withUsage)
|
|
return { ...withTotal, enableBilling: mockEnableBilling }
|
|
},
|
|
}))
|
|
|
|
type ConfirmPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
|
|
|
|
const setup = (overrides: Partial<CreateAppModalProps> = {}) => {
|
|
const onConfirm = jest.fn<Promise<void>, [ConfirmPayload]>().mockResolvedValue(undefined)
|
|
const onHide = jest.fn<void, []>()
|
|
|
|
const props: CreateAppModalProps = {
|
|
show: true,
|
|
isEditModal: false,
|
|
appName: 'Test App',
|
|
appDescription: 'Test description',
|
|
appIconType: 'emoji',
|
|
appIcon: '🤖',
|
|
appIconBackground: '#FFEAD5',
|
|
appIconUrl: null,
|
|
appMode: AppModeEnum.CHAT,
|
|
appUseIconAsAnswerIcon: false,
|
|
max_active_requests: null,
|
|
onConfirm,
|
|
confirmDisabled: false,
|
|
onHide,
|
|
...overrides,
|
|
}
|
|
|
|
render(<CreateAppModal {...props} />)
|
|
return { onConfirm, onHide }
|
|
}
|
|
|
|
const getAppIconTrigger = (): HTMLElement => {
|
|
const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder')
|
|
const iconRow = nameInput.parentElement?.parentElement
|
|
const iconTrigger = iconRow?.firstElementChild
|
|
if (!(iconTrigger instanceof HTMLElement))
|
|
throw new Error('Failed to locate app icon trigger')
|
|
return iconTrigger
|
|
}
|
|
|
|
describe('CreateAppModal', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
mockTranslationOverrides = {}
|
|
mockEnableBilling = false
|
|
mockPlanType = Plan.team
|
|
mockUsagePlanInfo = createPlanInfo(1)
|
|
mockTotalPlanInfo = createPlanInfo(10)
|
|
})
|
|
|
|
// The title and form sections vary based on the modal mode (create vs edit).
|
|
describe('Rendering', () => {
|
|
test('should render create title and actions when creating', () => {
|
|
setup({ appName: 'My App', isEditModal: false })
|
|
|
|
expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
|
})
|
|
|
|
test('should render edit-only fields when editing a chat app', () => {
|
|
setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 })
|
|
|
|
expect(screen.getByText('app.editAppTitle')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
|
|
expect(screen.getByRole('switch')).toBeInTheDocument()
|
|
expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5')
|
|
})
|
|
|
|
test.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', (mode) => {
|
|
setup({ isEditModal: true, appMode: mode })
|
|
|
|
expect(screen.getByRole('switch')).toBeInTheDocument()
|
|
})
|
|
|
|
test('should not render answer icon switch when editing a non-chat app', () => {
|
|
setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION })
|
|
|
|
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('should not render modal content when hidden', () => {
|
|
setup({ show: false })
|
|
|
|
expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// Disabled states prevent submission and reflect parent-driven props.
|
|
describe('Props', () => {
|
|
test('should disable confirm action when confirmDisabled is true', () => {
|
|
setup({ confirmDisabled: true })
|
|
|
|
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
|
|
})
|
|
|
|
test('should disable confirm action when appName is empty', () => {
|
|
setup({ appName: ' ' })
|
|
|
|
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
|
|
})
|
|
})
|
|
|
|
// Defensive coverage for falsy input values and translation edge cases.
|
|
describe('Edge Cases', () => {
|
|
test('should default description to empty string when appDescription is empty', () => {
|
|
setup({ appDescription: '' })
|
|
|
|
expect((screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder') as HTMLTextAreaElement).value).toBe('')
|
|
})
|
|
|
|
test('should fall back to empty placeholders when translations return empty string', () => {
|
|
mockTranslationOverrides = {
|
|
'app.newApp.appNamePlaceholder': '',
|
|
'app.newApp.appDescriptionPlaceholder': '',
|
|
}
|
|
|
|
setup()
|
|
|
|
expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('')
|
|
expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('')
|
|
})
|
|
})
|
|
|
|
// The modal should close from user-initiated cancellation actions.
|
|
describe('User Interactions', () => {
|
|
test('should call onHide when cancel button is clicked', () => {
|
|
const { onConfirm, onHide } = setup()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
|
|
|
expect(onHide).toHaveBeenCalledTimes(1)
|
|
expect(onConfirm).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('should call onHide when pressing Escape while visible', () => {
|
|
const { onHide } = setup()
|
|
|
|
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
|
|
|
expect(onHide).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('should not call onHide when pressing Escape while hidden', () => {
|
|
const { onHide } = setup({ show: false })
|
|
|
|
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
|
|
|
expect(onHide).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// When billing limits are reached, the modal blocks app creation and shows quota guidance.
|
|
describe('Quota Gating', () => {
|
|
test('should show AppsFull and disable create when apps quota is reached', () => {
|
|
mockEnableBilling = true
|
|
mockPlanType = Plan.team
|
|
mockUsagePlanInfo = createPlanInfo(10)
|
|
mockTotalPlanInfo = createPlanInfo(10)
|
|
|
|
setup({ isEditModal: false })
|
|
|
|
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled()
|
|
})
|
|
|
|
test('should allow saving when apps quota is reached in edit mode', () => {
|
|
mockEnableBilling = true
|
|
mockPlanType = Plan.team
|
|
mockUsagePlanInfo = createPlanInfo(10)
|
|
mockTotalPlanInfo = createPlanInfo(10)
|
|
|
|
setup({ isEditModal: true })
|
|
|
|
expect(screen.queryByText('billing.apps.fullTip2')).not.toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeEnabled()
|
|
})
|
|
})
|
|
|
|
// Shortcut handlers are important for power users and must respect gating rules.
|
|
describe('Keyboard Shortcuts', () => {
|
|
beforeEach(() => {
|
|
jest.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers()
|
|
})
|
|
|
|
test.each([
|
|
['meta+enter', { metaKey: true }],
|
|
['ctrl+enter', { ctrlKey: true }],
|
|
])('should submit when %s is pressed while visible', (_, modifier) => {
|
|
const { onConfirm, onHide } = setup()
|
|
|
|
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, ...modifier })
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
expect(onConfirm).toHaveBeenCalledTimes(1)
|
|
expect(onHide).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('should not submit when modal is hidden', () => {
|
|
const { onConfirm, onHide } = setup({ show: false })
|
|
|
|
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
expect(onConfirm).not.toHaveBeenCalled()
|
|
expect(onHide).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('should not submit when apps quota is reached in create mode', () => {
|
|
mockEnableBilling = true
|
|
mockPlanType = Plan.team
|
|
mockUsagePlanInfo = createPlanInfo(10)
|
|
mockTotalPlanInfo = createPlanInfo(10)
|
|
|
|
const { onConfirm, onHide } = setup({ isEditModal: false })
|
|
|
|
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
expect(onConfirm).not.toHaveBeenCalled()
|
|
expect(onHide).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('should submit when apps quota is reached in edit mode', () => {
|
|
mockEnableBilling = true
|
|
mockPlanType = Plan.team
|
|
mockUsagePlanInfo = createPlanInfo(10)
|
|
mockTotalPlanInfo = createPlanInfo(10)
|
|
|
|
const { onConfirm, onHide } = setup({ isEditModal: true })
|
|
|
|
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
expect(onConfirm).toHaveBeenCalledTimes(1)
|
|
expect(onHide).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('should not submit when name is empty', () => {
|
|
const { onConfirm, onHide } = setup({ appName: ' ' })
|
|
|
|
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
expect(onConfirm).not.toHaveBeenCalled()
|
|
expect(onHide).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// The app icon picker is a key user flow for customizing metadata.
|
|
describe('App Icon Picker', () => {
|
|
test('should open and close the picker when cancel is clicked', () => {
|
|
setup({
|
|
appIconType: 'image',
|
|
appIcon: 'file-123',
|
|
appIconUrl: 'https://example.com/icon.png',
|
|
})
|
|
|
|
fireEvent.click(getAppIconTrigger())
|
|
|
|
expect(screen.getByRole('button', { name: 'app.iconPicker.cancel' })).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
|
|
|
|
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
|
})
|
|
|
|
test('should update icon payload when selecting emoji and confirming', () => {
|
|
jest.useFakeTimers()
|
|
try {
|
|
const { onConfirm } = setup({
|
|
appIconType: 'image',
|
|
appIcon: 'file-123',
|
|
appIconUrl: 'https://example.com/icon.png',
|
|
})
|
|
|
|
fireEvent.click(getAppIconTrigger())
|
|
|
|
const emoji = document.querySelector('em-emoji[id="😀"]')
|
|
if (!(emoji instanceof HTMLElement))
|
|
throw new Error('Failed to locate emoji option in icon picker')
|
|
fireEvent.click(emoji)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
expect(onConfirm).toHaveBeenCalledTimes(1)
|
|
const payload = onConfirm.mock.calls[0][0]
|
|
expect(payload).toMatchObject({
|
|
icon_type: 'emoji',
|
|
icon: '😀',
|
|
icon_background: '#FFEAD5',
|
|
})
|
|
}
|
|
finally {
|
|
jest.useRealTimers()
|
|
}
|
|
})
|
|
|
|
test('should reset emoji icon to initial props when picker is cancelled', () => {
|
|
setup({
|
|
appIconType: 'emoji',
|
|
appIcon: '🤖',
|
|
appIconBackground: '#FFEAD5',
|
|
})
|
|
|
|
expect(document.querySelector('em-emoji[id="🤖"]')).toBeInTheDocument()
|
|
|
|
fireEvent.click(getAppIconTrigger())
|
|
|
|
const emoji = document.querySelector('em-emoji[id="😀"]')
|
|
if (!(emoji instanceof HTMLElement))
|
|
throw new Error('Failed to locate emoji option in icon picker')
|
|
fireEvent.click(emoji)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
|
|
|
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
|
expect(document.querySelector('em-emoji[id="😀"]')).toBeInTheDocument()
|
|
|
|
fireEvent.click(getAppIconTrigger())
|
|
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
|
|
|
|
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
|
expect(document.querySelector('em-emoji[id="🤖"]')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// Submitting uses a debounced handler and builds a payload from current form state.
|
|
describe('Submitting', () => {
|
|
beforeEach(() => {
|
|
jest.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers()
|
|
})
|
|
|
|
test('should call onConfirm with emoji payload and hide when create is clicked', () => {
|
|
const { onConfirm, onHide } = setup({
|
|
appName: 'My App',
|
|
appDescription: 'My description',
|
|
appIconType: 'emoji',
|
|
appIcon: '😀',
|
|
appIconBackground: '#000000',
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
expect(onConfirm).toHaveBeenCalledTimes(1)
|
|
expect(onHide).toHaveBeenCalledTimes(1)
|
|
|
|
const payload = onConfirm.mock.calls[0][0]
|
|
expect(payload).toMatchObject({
|
|
name: 'My App',
|
|
icon_type: 'emoji',
|
|
icon: '😀',
|
|
icon_background: '#000000',
|
|
description: 'My description',
|
|
use_icon_as_answer_icon: false,
|
|
})
|
|
expect(payload).not.toHaveProperty('max_active_requests')
|
|
})
|
|
|
|
test('should include updated description when textarea is changed before submitting', () => {
|
|
const { onConfirm } = setup({ appDescription: 'Old description' })
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
expect(onConfirm).toHaveBeenCalledTimes(1)
|
|
expect(onConfirm.mock.calls[0][0]).toMatchObject({ description: 'Updated description' })
|
|
})
|
|
|
|
test('should omit icon_background when submitting with image icon', () => {
|
|
const { onConfirm } = setup({
|
|
appIconType: 'image',
|
|
appIcon: 'file-123',
|
|
appIconUrl: 'https://example.com/icon.png',
|
|
appIconBackground: null,
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
const payload = onConfirm.mock.calls[0][0]
|
|
expect(payload).toMatchObject({
|
|
icon_type: 'image',
|
|
icon: 'file-123',
|
|
})
|
|
expect(payload.icon_background).toBeUndefined()
|
|
})
|
|
|
|
test('should include max_active_requests and updated answer icon when saving', () => {
|
|
const { onConfirm } = setup({
|
|
isEditModal: true,
|
|
appMode: AppModeEnum.CHAT,
|
|
appUseIconAsAnswerIcon: false,
|
|
max_active_requests: 3,
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('switch'))
|
|
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
const payload = onConfirm.mock.calls[0][0]
|
|
expect(payload).toMatchObject({
|
|
use_icon_as_answer_icon: true,
|
|
max_active_requests: 12,
|
|
})
|
|
})
|
|
|
|
test('should omit max_active_requests when input is empty', () => {
|
|
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
const payload = onConfirm.mock.calls[0][0]
|
|
expect(payload.max_active_requests).toBeUndefined()
|
|
})
|
|
|
|
test('should omit max_active_requests when input is not a number', () => {
|
|
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
|
|
|
|
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
const payload = onConfirm.mock.calls[0][0]
|
|
expect(payload.max_active_requests).toBeUndefined()
|
|
})
|
|
|
|
test('should show toast error and not submit when name becomes empty before debounced submit runs', () => {
|
|
const { onConfirm, onHide } = setup({ appName: 'My App' })
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' }))
|
|
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } })
|
|
|
|
act(() => {
|
|
jest.advanceTimersByTime(300)
|
|
})
|
|
|
|
expect(screen.getByText('explore.appCustomize.nameRequired')).toBeInTheDocument()
|
|
act(() => {
|
|
jest.advanceTimersByTime(6000)
|
|
})
|
|
expect(screen.queryByText('explore.appCustomize.nameRequired')).not.toBeInTheDocument()
|
|
expect(onConfirm).not.toHaveBeenCalled()
|
|
expect(onHide).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|