Compare commits

...

5 Commits

Author SHA1 Message Date
yyh
cc458e1adc Merge branch 'main' into chore/jest1 2025-12-17 13:43:19 +08:00
yyh
fc89535921 feat(tests): add unit tests for InstalledApp component with comprehensive mock setups and context integration 2025-12-17 13:42:58 +08:00
Joel
94a5fd3617 chore: tests for webapp run batch (#29767) 2025-12-17 13:36:50 +08:00
Joel
5bb1346da8 chore: tests form add annotation (#29770)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-17 13:36:40 +08:00
yyh
dcb692e874 feat(tests): add unit tests for DebugWithSingleModel component with comprehensive mock setups and context integration 2025-12-17 13:28:50 +08:00
8 changed files with 2174 additions and 1 deletions

View File

@@ -0,0 +1,53 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import EditItem, { EditItemType } from './index'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('AddAnnotationModal/EditItem', () => {
test('should render query inputs with user avatar and placeholder strings', () => {
render(
<EditItem
type={EditItemType.Query}
content="Why?"
onChange={jest.fn()}
/>,
)
expect(screen.getByText('appAnnotation.addModal.queryName')).toBeInTheDocument()
expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toBeInTheDocument()
expect(screen.getByText('Why?')).toBeInTheDocument()
})
test('should render answer name and placeholder text', () => {
render(
<EditItem
type={EditItemType.Answer}
content="Existing answer"
onChange={jest.fn()}
/>,
)
expect(screen.getByText('appAnnotation.addModal.answerName')).toBeInTheDocument()
expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toBeInTheDocument()
expect(screen.getByDisplayValue('Existing answer')).toBeInTheDocument()
})
test('should propagate changes when answer content updates', () => {
const handleChange = jest.fn()
render(
<EditItem
type={EditItemType.Answer}
content=""
onChange={handleChange}
/>,
)
fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), { target: { value: 'Because' } })
expect(handleChange).toHaveBeenCalledWith('Because')
})
})

View File

@@ -0,0 +1,155 @@
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import AddAnnotationModal from './index'
import { useProviderContext } from '@/context/provider-context'
jest.mock('@/context/provider-context', () => ({
useProviderContext: jest.fn(),
}))
const mockToastNotify = jest.fn()
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: jest.fn(args => mockToastNotify(args)),
},
}))
jest.mock('@/app/components/billing/annotation-full', () => () => <div data-testid="annotation-full" />)
const mockUseProviderContext = useProviderContext as jest.Mock
const getProviderContext = ({ usage = 0, total = 10, enableBilling = false } = {}) => ({
plan: {
usage: { annotatedResponse: usage },
total: { annotatedResponse: total },
},
enableBilling,
})
describe('AddAnnotationModal', () => {
const baseProps = {
isShow: true,
onHide: jest.fn(),
onAdd: jest.fn(),
}
beforeEach(() => {
jest.clearAllMocks()
mockUseProviderContext.mockReturnValue(getProviderContext())
})
const typeQuestion = (value: string) => {
fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder'), {
target: { value },
})
}
const typeAnswer = (value: string) => {
fireEvent.change(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder'), {
target: { value },
})
}
test('should render modal title when drawer is visible', () => {
render(<AddAnnotationModal {...baseProps} />)
expect(screen.getByText('appAnnotation.addModal.title')).toBeInTheDocument()
})
test('should capture query input text when typing', () => {
render(<AddAnnotationModal {...baseProps} />)
typeQuestion('Sample question')
expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('Sample question')
})
test('should capture answer input text when typing', () => {
render(<AddAnnotationModal {...baseProps} />)
typeAnswer('Sample answer')
expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('Sample answer')
})
test('should show annotation full notice and disable submit when quota exceeded', () => {
mockUseProviderContext.mockReturnValue(getProviderContext({ usage: 10, total: 10, enableBilling: true }))
render(<AddAnnotationModal {...baseProps} />)
expect(screen.getByTestId('annotation-full')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.add' })).toBeDisabled()
})
test('should call onAdd with form values when create next enabled', async () => {
const onAdd = jest.fn().mockResolvedValue(undefined)
render(<AddAnnotationModal {...baseProps} onAdd={onAdd} />)
typeQuestion('Question value')
typeAnswer('Answer value')
fireEvent.click(screen.getByTestId('checkbox-create-next-checkbox'))
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
})
expect(onAdd).toHaveBeenCalledWith({ question: 'Question value', answer: 'Answer value' })
})
test('should reset fields after saving when create next enabled', async () => {
const onAdd = jest.fn().mockResolvedValue(undefined)
render(<AddAnnotationModal {...baseProps} onAdd={onAdd} />)
typeQuestion('Question value')
typeAnswer('Answer value')
const createNextToggle = screen.getByText('appAnnotation.addModal.createNext').previousElementSibling as HTMLElement
fireEvent.click(createNextToggle)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
})
await waitFor(() => {
expect(screen.getByPlaceholderText('appAnnotation.addModal.queryPlaceholder')).toHaveValue('')
expect(screen.getByPlaceholderText('appAnnotation.addModal.answerPlaceholder')).toHaveValue('')
})
})
test('should show toast when validation fails for missing question', () => {
render(<AddAnnotationModal {...baseProps} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'appAnnotation.errorMessage.queryRequired',
}))
})
test('should show toast when validation fails for missing answer', () => {
render(<AddAnnotationModal {...baseProps} />)
typeQuestion('Filled question')
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'appAnnotation.errorMessage.answerRequired',
}))
})
test('should close modal when save completes and create next unchecked', async () => {
const onAdd = jest.fn().mockResolvedValue(undefined)
render(<AddAnnotationModal {...baseProps} onAdd={onAdd} />)
typeQuestion('Q')
typeAnswer('A')
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
})
expect(baseProps.onHide).toHaveBeenCalled()
})
test('should allow cancel button to close the drawer', () => {
render(<AddAnnotationModal {...baseProps} />)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(baseProps.onHide).toHaveBeenCalled()
})
})

View File

@@ -101,7 +101,7 @@ const AddAnnotationModal: FC<Props> = ({
<div
className='flex items-center space-x-2'
>
<Checkbox checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} />
<Checkbox id='create-next-checkbox' checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} />
<div>{t('appAnnotation.addModal.createNext')}</div>
</div>
<div className='mt-2 flex space-x-2'>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,738 @@
import { render, screen, waitFor } from '@testing-library/react'
import { AppModeEnum } from '@/types/app'
import { AccessMode } from '@/models/access-control'
// Mock external dependencies BEFORE imports
jest.mock('use-context-selector', () => ({
useContext: jest.fn(),
createContext: jest.fn(() => ({})),
}))
jest.mock('@/context/web-app-context', () => ({
useWebAppStore: jest.fn(),
}))
jest.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: jest.fn(),
}))
jest.mock('@/service/use-explore', () => ({
useGetInstalledAppAccessModeByAppId: jest.fn(),
useGetInstalledAppParams: jest.fn(),
useGetInstalledAppMeta: jest.fn(),
}))
import { useContext } from 'use-context-selector'
import InstalledApp from './index'
import { useWebAppStore } from '@/context/web-app-context'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
import type { InstalledApp as InstalledAppType } from '@/models/explore'
/**
* Mock child components for unit testing
*
* RATIONALE FOR MOCKING:
* - TextGenerationApp: 648 lines, complex batch processing, task management, file uploads
* - ChatWithHistory: 576-line custom hook, complex conversation/history management, 30+ context values
*
* These components are too complex to test as real components. Using real components would:
* 1. Require mocking dozens of their dependencies (services, contexts, hooks)
* 2. Make tests fragile and coupled to child component implementation details
* 3. Violate the principle of testing one component in isolation
*
* For a container component like InstalledApp, its responsibility is to:
* - Correctly route to the appropriate child component based on app mode
* - Pass the correct props to child components
* - Handle loading/error states before rendering children
*
* The internal logic of ChatWithHistory and TextGenerationApp should be tested
* in their own dedicated test files.
*/
jest.mock('@/app/components/share/text-generation', () => ({
__esModule: true,
default: ({ isInstalledApp, installedAppInfo, isWorkflow }: {
isInstalledApp?: boolean
installedAppInfo?: InstalledAppType
isWorkflow?: boolean
}) => (
<div data-testid="text-generation-app">
Text Generation App
{isWorkflow && ' (Workflow)'}
{isInstalledApp && ` - ${installedAppInfo?.id}`}
</div>
),
}))
jest.mock('@/app/components/base/chat/chat-with-history', () => ({
__esModule: true,
default: ({ installedAppInfo, className }: {
installedAppInfo?: InstalledAppType
className?: string
}) => (
<div data-testid="chat-with-history" className={className}>
Chat With History - {installedAppInfo?.id}
</div>
),
}))
describe('InstalledApp', () => {
const mockUpdateAppInfo = jest.fn()
const mockUpdateWebAppAccessMode = jest.fn()
const mockUpdateAppParams = jest.fn()
const mockUpdateWebAppMeta = jest.fn()
const mockUpdateUserCanAccessApp = jest.fn()
const mockInstalledApp = {
id: 'installed-app-123',
app: {
id: 'app-123',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon_type: 'emoji' as const,
icon: '🚀',
icon_background: '#FFFFFF',
icon_url: '',
description: 'Test description',
use_icon_as_answer_icon: false,
},
uninstallable: true,
is_pinned: false,
}
const mockAppParams = {
user_input_form: [],
file_upload: { image: { enabled: false, number_limits: 0, transfer_methods: [] } },
system_parameters: {},
}
const mockAppMeta = {
tool_icons: {},
}
const mockWebAppAccessMode = {
accessMode: AccessMode.PUBLIC,
}
const mockUserCanAccessApp = {
result: true,
}
beforeEach(() => {
jest.clearAllMocks()
// Mock useContext
;(useContext as jest.Mock).mockReturnValue({
installedApps: [mockInstalledApp],
isFetchingInstalledApps: false,
})
// Mock useWebAppStore
;(useWebAppStore as unknown as jest.Mock).mockImplementation((
selector: (state: {
updateAppInfo: jest.Mock
updateWebAppAccessMode: jest.Mock
updateAppParams: jest.Mock
updateWebAppMeta: jest.Mock
updateUserCanAccessApp: jest.Mock
}) => unknown,
) => {
const state = {
updateAppInfo: mockUpdateAppInfo,
updateWebAppAccessMode: mockUpdateWebAppAccessMode,
updateAppParams: mockUpdateAppParams,
updateWebAppMeta: mockUpdateWebAppMeta,
updateUserCanAccessApp: mockUpdateUserCanAccessApp,
}
return selector(state)
})
// Mock service hooks with default success states
;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
isFetching: false,
data: mockWebAppAccessMode,
error: null,
})
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: false,
data: mockAppParams,
error: null,
})
;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
isFetching: false,
data: mockAppMeta,
error: null,
})
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: mockUserCanAccessApp,
error: null,
})
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
})
it('should render loading state when fetching app params', () => {
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: true,
data: null,
error: null,
})
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
})
it('should render loading state when fetching app meta', () => {
;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
isFetching: true,
data: null,
error: null,
})
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
})
it('should render loading state when fetching web app access mode', () => {
;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
isFetching: true,
data: null,
error: null,
})
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
})
it('should render loading state when fetching installed apps', () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [mockInstalledApp],
isFetchingInstalledApps: true,
})
const { container } = render(<InstalledApp id="installed-app-123" />)
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
})
it('should render app not found (404) when installedApp does not exist', () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="nonexistent-app" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
})
describe('Error States', () => {
it('should render error when app params fails to load', () => {
const error = new Error('Failed to load app params')
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Failed to load app params/)).toBeInTheDocument()
})
it('should render error when app meta fails to load', () => {
const error = new Error('Failed to load app meta')
;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Failed to load app meta/)).toBeInTheDocument()
})
it('should render error when web app access mode fails to load', () => {
const error = new Error('Failed to load access mode')
;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Failed to load access mode/)).toBeInTheDocument()
})
it('should render error when user access check fails', () => {
const error = new Error('Failed to check user access')
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: null,
error,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/Failed to check user access/)).toBeInTheDocument()
})
it('should render no permission (403) when user cannot access app', () => {
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: { result: false },
error: null,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/403/)).toBeInTheDocument()
expect(screen.getByText(/no permission/i)).toBeInTheDocument()
})
})
describe('App Mode Rendering', () => {
it('should render ChatWithHistory for CHAT mode', () => {
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument()
})
it('should render ChatWithHistory for ADVANCED_CHAT mode', () => {
const advancedChatApp = {
...mockInstalledApp,
app: {
...mockInstalledApp.app,
mode: AppModeEnum.ADVANCED_CHAT,
},
}
;(useContext as jest.Mock).mockReturnValue({
installedApps: [advancedChatApp],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument()
})
it('should render ChatWithHistory for AGENT_CHAT mode', () => {
const agentChatApp = {
...mockInstalledApp,
app: {
...mockInstalledApp.app,
mode: AppModeEnum.AGENT_CHAT,
},
}
;(useContext as jest.Mock).mockReturnValue({
installedApps: [agentChatApp],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
expect(screen.queryByTestId('text-generation-app')).not.toBeInTheDocument()
})
it('should render TextGenerationApp for COMPLETION mode', () => {
const completionApp = {
...mockInstalledApp,
app: {
...mockInstalledApp.app,
mode: AppModeEnum.COMPLETION,
},
}
;(useContext as jest.Mock).mockReturnValue({
installedApps: [completionApp],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
expect(screen.getByText(/Text Generation App/)).toBeInTheDocument()
expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument()
})
it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => {
const workflowApp = {
...mockInstalledApp,
app: {
...mockInstalledApp.app,
mode: AppModeEnum.WORKFLOW,
},
}
;(useContext as jest.Mock).mockReturnValue({
installedApps: [workflowApp],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
expect(screen.getByText(/Workflow/)).toBeInTheDocument()
})
})
describe('Props', () => {
it('should use id prop to find installed app', () => {
const app1 = { ...mockInstalledApp, id: 'app-1' }
const app2 = { ...mockInstalledApp, id: 'app-2' }
;(useContext as jest.Mock).mockReturnValue({
installedApps: [app1, app2],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="app-2" />)
expect(screen.getByText(/app-2/)).toBeInTheDocument()
})
it('should handle id that does not match any installed app', () => {
render(<InstalledApp id="nonexistent-id" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
})
describe('Effects', () => {
it('should update app info when installedApp is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalledWith(
expect.objectContaining({
app_id: 'installed-app-123',
site: expect.objectContaining({
title: 'Test App',
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FFFFFF',
icon_url: '',
prompt_public: false,
copyright: '',
show_workflow_steps: true,
use_icon_as_answer_icon: false,
}),
plan: 'basic',
custom_config: null,
}),
)
})
})
it('should update app info to null when installedApp is not found', async () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="nonexistent-app" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalledWith(null)
})
})
it('should update app params when data is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams)
})
})
it('should update app meta when data is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateWebAppMeta).toHaveBeenCalledWith(mockAppMeta)
})
})
it('should update web app access mode when data is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC)
})
})
it('should update user can access app when data is available', async () => {
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true)
})
})
it('should update user can access app to false when result is false', async () => {
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: { result: false },
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false)
})
})
it('should update user can access app to false when data is null', async () => {
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: null,
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(false)
})
})
it('should not update app params when data is null', async () => {
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalled()
})
expect(mockUpdateAppParams).not.toHaveBeenCalled()
})
it('should not update app meta when data is null', async () => {
;(useGetInstalledAppMeta as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalled()
})
expect(mockUpdateWebAppMeta).not.toHaveBeenCalled()
})
it('should not update access mode when data is null', async () => {
;(useGetInstalledAppAccessModeByAppId as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error: null,
})
render(<InstalledApp id="installed-app-123" />)
await waitFor(() => {
expect(mockUpdateAppInfo).toHaveBeenCalled()
})
expect(mockUpdateWebAppAccessMode).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty installedApps array', () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/404/)).toBeInTheDocument()
})
it('should handle multiple installed apps and find the correct one', () => {
const otherApp = {
...mockInstalledApp,
id: 'other-app-id',
app: {
...mockInstalledApp.app,
name: 'Other App',
},
}
;(useContext as jest.Mock).mockReturnValue({
installedApps: [otherApp, mockInstalledApp],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="installed-app-123" />)
// Should find and render the correct app
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
expect(screen.getByText(/installed-app-123/)).toBeInTheDocument()
})
it('should apply correct CSS classes to container', () => {
const { container } = render(<InstalledApp id="installed-app-123" />)
const mainDiv = container.firstChild as HTMLElement
expect(mainDiv).toHaveClass('h-full', 'bg-background-default', 'py-2', 'pl-0', 'pr-2', 'sm:p-2')
})
it('should apply correct CSS classes to ChatWithHistory', () => {
render(<InstalledApp id="installed-app-123" />)
const chatComponent = screen.getByTestId('chat-with-history')
expect(chatComponent).toHaveClass('overflow-hidden', 'rounded-2xl', 'shadow-md')
})
it('should handle rapid id prop changes', async () => {
const app1 = { ...mockInstalledApp, id: 'app-1' }
const app2 = { ...mockInstalledApp, id: 'app-2' }
;(useContext as jest.Mock).mockReturnValue({
installedApps: [app1, app2],
isFetchingInstalledApps: false,
})
const { rerender } = render(<InstalledApp id="app-1" />)
expect(screen.getByText(/app-1/)).toBeInTheDocument()
rerender(<InstalledApp id="app-2" />)
expect(screen.getByText(/app-2/)).toBeInTheDocument()
})
it('should call service hooks with correct appId', () => {
render(<InstalledApp id="installed-app-123" />)
expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith('installed-app-123')
expect(useGetInstalledAppParams).toHaveBeenCalledWith('installed-app-123')
expect(useGetInstalledAppMeta).toHaveBeenCalledWith('installed-app-123')
expect(useGetUserCanAccessApp).toHaveBeenCalledWith({
appId: 'app-123',
isInstalledApp: true,
})
})
it('should call service hooks with null when installedApp is not found', () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
render(<InstalledApp id="nonexistent-app" />)
expect(useGetInstalledAppAccessModeByAppId).toHaveBeenCalledWith(null)
expect(useGetInstalledAppParams).toHaveBeenCalledWith(null)
expect(useGetInstalledAppMeta).toHaveBeenCalledWith(null)
expect(useGetUserCanAccessApp).toHaveBeenCalledWith({
appId: undefined,
isInstalledApp: true,
})
})
})
describe('Component Memoization', () => {
it('should be wrapped with React.memo', () => {
// React.memo wraps the component with a special $$typeof symbol
const componentType = (InstalledApp as React.MemoExoticComponent<typeof InstalledApp>).$$typeof
expect(componentType).toBeDefined()
})
it('should re-render when props change', () => {
const { rerender } = render(<InstalledApp id="installed-app-123" />)
expect(screen.getByText(/installed-app-123/)).toBeInTheDocument()
// Change to a different app
const differentApp = {
...mockInstalledApp,
id: 'different-app-456',
app: {
...mockInstalledApp.app,
name: 'Different App',
},
}
;(useContext as jest.Mock).mockReturnValue({
installedApps: [differentApp],
isFetchingInstalledApps: false,
})
rerender(<InstalledApp id="different-app-456" />)
expect(screen.getByText(/different-app-456/)).toBeInTheDocument()
})
it('should maintain component stability across re-renders with same props', () => {
const { rerender } = render(<InstalledApp id="installed-app-123" />)
const initialCallCount = mockUpdateAppInfo.mock.calls.length
// Rerender with same props - useEffect may still run due to dependencies
rerender(<InstalledApp id="installed-app-123" />)
// Component should render successfully
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
// Mock calls might increase due to useEffect, but component should be stable
expect(mockUpdateAppInfo.mock.calls.length).toBeGreaterThanOrEqual(initialCallCount)
})
})
describe('Render Priority', () => {
it('should show error before loading state', () => {
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: true,
data: null,
error: new Error('Some error'),
})
render(<InstalledApp id="installed-app-123" />)
// Error should take precedence over loading
expect(screen.getByText(/Some error/)).toBeInTheDocument()
})
it('should show error before permission check', () => {
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: false,
data: null,
error: new Error('Params error'),
})
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: { result: false },
error: null,
})
render(<InstalledApp id="installed-app-123" />)
// Error should take precedence over permission
expect(screen.getByText(/Params error/)).toBeInTheDocument()
expect(screen.queryByText(/403/)).not.toBeInTheDocument()
})
it('should show permission error before 404', () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
;(useGetUserCanAccessApp as jest.Mock).mockReturnValue({
data: { result: false },
error: null,
})
render(<InstalledApp id="nonexistent-app" />)
// Permission should take precedence over 404
expect(screen.getByText(/403/)).toBeInTheDocument()
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
})
it('should show loading before 404', () => {
;(useContext as jest.Mock).mockReturnValue({
installedApps: [],
isFetchingInstalledApps: false,
})
;(useGetInstalledAppParams as jest.Mock).mockReturnValue({
isFetching: true,
data: null,
error: null,
})
const { container } = render(<InstalledApp id="nonexistent-app" />)
// Loading should take precedence over 404
const svg = container.querySelector('svg.spin-animation')
expect(svg).toBeInTheDocument()
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,49 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import CSVDownload from './index'
const mockType = { Link: 'mock-link' }
let capturedProps: Record<string, unknown> | undefined
jest.mock('react-papaparse', () => ({
useCSVDownloader: () => {
const CSVDownloader = ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
capturedProps = props
return <div data-testid="csv-downloader" className={props.className as string}>{children}</div>
}
return {
CSVDownloader,
Type: mockType,
}
},
}))
describe('CSVDownload', () => {
const vars = [{ name: 'prompt' }, { name: 'context' }]
beforeEach(() => {
capturedProps = undefined
jest.clearAllMocks()
})
test('should render table headers and sample row for each variable', () => {
render(<CSVDownload vars={vars} />)
expect(screen.getByText('share.generation.csvStructureTitle')).toBeInTheDocument()
expect(screen.getAllByRole('row')[0].children).toHaveLength(2)
expect(screen.getByText('prompt share.generation.field')).toBeInTheDocument()
expect(screen.getByText('context share.generation.field')).toBeInTheDocument()
})
test('should configure CSV downloader with template data', () => {
render(<CSVDownload vars={vars} />)
expect(capturedProps?.filename).toBe('template')
expect(capturedProps?.type).toBe(mockType.Link)
expect(capturedProps?.bom).toBe(true)
expect(capturedProps?.data).toEqual([
{ prompt: '', context: '' },
])
expect(screen.getByText('share.generation.downloadTemplate')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,70 @@
import React from 'react'
import { act, render, screen, waitFor } from '@testing-library/react'
import CSVReader from './index'
let mockAcceptedFile: { name: string } | null = null
let capturedHandlers: Record<string, (payload: any) => void> = {}
jest.mock('react-papaparse', () => ({
useCSVReader: () => ({
CSVReader: ({ children, ...handlers }: any) => {
capturedHandlers = handlers
return (
<div data-testid="csv-reader-wrapper">
{children({
getRootProps: () => ({ 'data-testid': 'drop-zone' }),
acceptedFile: mockAcceptedFile,
})}
</div>
)
},
}),
}))
describe('CSVReader', () => {
beforeEach(() => {
mockAcceptedFile = null
capturedHandlers = {}
jest.clearAllMocks()
})
test('should display upload instructions when no file selected', async () => {
const onParsed = jest.fn()
render(<CSVReader onParsed={onParsed} />)
expect(screen.getByText('share.generation.csvUploadTitle')).toBeInTheDocument()
expect(screen.getByText('share.generation.browse')).toBeInTheDocument()
await act(async () => {
capturedHandlers.onUploadAccepted?.({ data: [['row1']] })
})
expect(onParsed).toHaveBeenCalledWith([['row1']])
})
test('should show accepted file name without extension', () => {
mockAcceptedFile = { name: 'batch.csv' }
render(<CSVReader onParsed={jest.fn()} />)
expect(screen.getByText('batch')).toBeInTheDocument()
expect(screen.getByText('.csv')).toBeInTheDocument()
})
test('should toggle hover styling on drag events', async () => {
render(<CSVReader onParsed={jest.fn()} />)
const dragEvent = { preventDefault: jest.fn() } as unknown as DragEvent
await act(async () => {
capturedHandlers.onDragOver?.(dragEvent)
})
await waitFor(() => {
expect(screen.getByTestId('drop-zone')).toHaveClass('border-components-dropzone-border-accent')
})
await act(async () => {
capturedHandlers.onDragLeave?.(dragEvent)
})
await waitFor(() => {
expect(screen.getByTestId('drop-zone')).not.toHaveClass('border-components-dropzone-border-accent')
})
})
})

View File

@@ -0,0 +1,88 @@
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import RunBatch from './index'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
jest.mock('@/hooks/use-breakpoints', () => {
const actual = jest.requireActual('@/hooks/use-breakpoints')
return {
__esModule: true,
default: jest.fn(),
MediaType: actual.MediaType,
}
})
let latestOnParsed: ((data: string[][]) => void) | undefined
let receivedCSVDownloadProps: Record<string, unknown> | undefined
jest.mock('./csv-reader', () => (props: { onParsed: (data: string[][]) => void }) => {
latestOnParsed = props.onParsed
return <div data-testid="csv-reader" />
})
jest.mock('./csv-download', () => (props: { vars: { name: string }[] }) => {
receivedCSVDownloadProps = props
return <div data-testid="csv-download" />
})
const mockUseBreakpoints = useBreakpoints as jest.Mock
describe('RunBatch', () => {
const vars = [{ name: 'prompt' }]
beforeEach(() => {
mockUseBreakpoints.mockReturnValue(MediaType.pc)
latestOnParsed = undefined
receivedCSVDownloadProps = undefined
jest.clearAllMocks()
})
test('should enable run button after CSV parsed and send data', async () => {
const onSend = jest.fn()
render(
<RunBatch
vars={vars}
onSend={onSend}
isAllFinished
/>,
)
expect(receivedCSVDownloadProps?.vars).toEqual(vars)
await act(async () => {
latestOnParsed?.([['row1']])
})
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
await waitFor(() => {
expect(runButton).not.toBeDisabled()
})
fireEvent.click(runButton)
expect(onSend).toHaveBeenCalledWith([['row1']])
})
test('should keep button disabled and show spinner when results still running on mobile', async () => {
mockUseBreakpoints.mockReturnValue(MediaType.mobile)
const onSend = jest.fn()
const { container } = render(
<RunBatch
vars={vars}
onSend={onSend}
isAllFinished={false}
/>,
)
await act(async () => {
latestOnParsed?.([['row']])
})
const runButton = screen.getByRole('button', { name: 'share.generation.run' })
await waitFor(() => {
expect(runButton).toBeDisabled()
})
expect(runButton).toHaveClass('grow')
const icon = container.querySelector('svg')
expect(icon).toHaveClass('animate-spin')
expect(onSend).not.toHaveBeenCalled()
})
})