mirror of https://github.com/langgenius/dify.git
feat(tests): add integration tests for explore app list, installed apps, and sidebar lifecycle flows (#32248)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
parent
b65678bd4c
commit
3fd1eea4d7
|
|
@ -0,0 +1,273 @@
|
|||
/**
|
||||
* Integration test: Explore App List Flow
|
||||
*
|
||||
* Tests the end-to-end user flow of browsing, filtering, searching,
|
||||
* and adding apps to workspace from the explore page.
|
||||
*/
|
||||
import type { Mock } from 'vitest'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { App } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import AppList from '@/app/components/explore/app-list'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
|
||||
let mockTabValue = allCategoriesEn
|
||||
const mockSetTab = vi.fn()
|
||||
let mockExploreData: { categories: string[], allList: App[] } | undefined
|
||||
let mockIsLoading = false
|
||||
const mockHandleImportDSL = vi.fn()
|
||||
const mockHandleImportDSLConfirm = vi.fn()
|
||||
|
||||
vi.mock('nuqs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('nuqs')>()
|
||||
return {
|
||||
...actual,
|
||||
useQueryState: () => [mockTabValue, mockSetTab],
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
|
||||
const React = await vi.importActual<typeof import('react')>('react')
|
||||
return {
|
||||
...actual,
|
||||
useDebounceFn: (fn: (...args: unknown[]) => void) => {
|
||||
const fnRef = React.useRef(fn)
|
||||
fnRef.current = fn
|
||||
return {
|
||||
run: () => setTimeout(() => fnRef.current(), 0),
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useExploreAppList: () => ({
|
||||
data: mockExploreData,
|
||||
isLoading: mockIsLoading,
|
||||
isError: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/explore', () => ({
|
||||
fetchAppDetail: vi.fn(),
|
||||
fetchAppList: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-import-dsl', () => ({
|
||||
useImportDSL: () => ({
|
||||
handleImportDSL: mockHandleImportDSL,
|
||||
handleImportDSLConfirm: mockHandleImportDSLConfirm,
|
||||
versions: ['v1'],
|
||||
isFetching: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/explore/create-app-modal', () => ({
|
||||
default: (props: CreateAppModalProps) => {
|
||||
if (!props.show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="create-app-modal">
|
||||
<button
|
||||
data-testid="confirm-create"
|
||||
onClick={() => props.onConfirm({
|
||||
name: 'New App',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
description: 'desc',
|
||||
})}
|
||||
>
|
||||
confirm
|
||||
</button>
|
||||
<button data-testid="hide-create" onClick={props.onHide}>hide</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal', () => ({
|
||||
default: ({ onConfirm, onCancel }: { onConfirm: () => void, onCancel: () => void }) => (
|
||||
<div data-testid="dsl-confirm-modal">
|
||||
<button data-testid="dsl-confirm" onClick={onConfirm}>confirm</button>
|
||||
<button data-testid="dsl-cancel" onClick={onCancel}>cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
app: {
|
||||
id: overrides.app?.id ?? 'app-id',
|
||||
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
|
||||
icon_type: overrides.app?.icon_type ?? 'emoji',
|
||||
icon: overrides.app?.icon ?? '😀',
|
||||
icon_background: overrides.app?.icon_background ?? '#fff',
|
||||
icon_url: overrides.app?.icon_url ?? '',
|
||||
name: overrides.app?.name ?? 'Alpha',
|
||||
description: overrides.app?.description ?? 'Alpha description',
|
||||
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
|
||||
},
|
||||
can_trial: true,
|
||||
app_id: overrides.app_id ?? 'app-1',
|
||||
description: overrides.description ?? 'Alpha description',
|
||||
copyright: overrides.copyright ?? '',
|
||||
privacy_policy: overrides.privacy_policy ?? null,
|
||||
custom_disclaimer: overrides.custom_disclaimer ?? null,
|
||||
category: overrides.category ?? 'Writing',
|
||||
position: overrides.position ?? 1,
|
||||
is_listed: overrides.is_listed ?? true,
|
||||
install_count: overrides.install_count ?? 0,
|
||||
installed: overrides.installed ?? false,
|
||||
editable: overrides.editable ?? false,
|
||||
is_agent: overrides.is_agent ?? false,
|
||||
})
|
||||
|
||||
const createContextValue = (hasEditPermission = true) => ({
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission,
|
||||
installedApps: [] as never[],
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: false,
|
||||
setShowTryAppPanel: vi.fn(),
|
||||
})
|
||||
|
||||
const wrapWithContext = (hasEditPermission = true, onSuccess?: () => void) => (
|
||||
<ExploreContext.Provider value={createContextValue(hasEditPermission)}>
|
||||
<AppList onSuccess={onSuccess} />
|
||||
</ExploreContext.Provider>
|
||||
)
|
||||
|
||||
const renderWithContext = (hasEditPermission = true, onSuccess?: () => void) => {
|
||||
return render(wrapWithContext(hasEditPermission, onSuccess))
|
||||
}
|
||||
|
||||
describe('Explore App List Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTabValue = allCategoriesEn
|
||||
mockIsLoading = false
|
||||
mockExploreData = {
|
||||
categories: ['Writing', 'Translate', 'Programming'],
|
||||
allList: [
|
||||
createApp({ app_id: 'app-1', app: { ...createApp().app, name: 'Writer Bot' }, category: 'Writing' }),
|
||||
createApp({ app_id: 'app-2', app: { ...createApp().app, id: 'app-id-2', name: 'Translator' }, category: 'Translate' }),
|
||||
createApp({ app_id: 'app-3', app: { ...createApp().app, id: 'app-id-3', name: 'Code Helper' }, category: 'Programming' }),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
describe('Browse and Filter Flow', () => {
|
||||
it('should display all apps when no category filter is applied', () => {
|
||||
renderWithContext()
|
||||
|
||||
expect(screen.getByText('Writer Bot')).toBeInTheDocument()
|
||||
expect(screen.getByText('Translator')).toBeInTheDocument()
|
||||
expect(screen.getByText('Code Helper')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter apps by selected category', () => {
|
||||
mockTabValue = 'Writing'
|
||||
renderWithContext()
|
||||
|
||||
expect(screen.getByText('Writer Bot')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Translator')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Code Helper')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter apps by search keyword', async () => {
|
||||
renderWithContext()
|
||||
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'trans' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Translator')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Writer Bot')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Code Helper')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add to Workspace Flow', () => {
|
||||
it('should complete the full add-to-workspace flow with DSL confirmation', async () => {
|
||||
// Step 1: User clicks "Add to Workspace" on an app card
|
||||
const onSuccess = vi.fn()
|
||||
;(fetchAppDetail as unknown as Mock).mockResolvedValue({ export_data: 'yaml-content' })
|
||||
mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void, onPending?: () => void }) => {
|
||||
options.onPending?.()
|
||||
})
|
||||
mockHandleImportDSLConfirm.mockImplementation(async (options: { onSuccess?: () => void }) => {
|
||||
options.onSuccess?.()
|
||||
})
|
||||
|
||||
renderWithContext(true, onSuccess)
|
||||
|
||||
// Step 2: Click add to workspace button - opens create modal
|
||||
fireEvent.click(screen.getAllByText('explore.appCard.addToWorkspace')[0])
|
||||
|
||||
// Step 3: Confirm creation in modal
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
// Step 4: API fetches app detail
|
||||
await waitFor(() => {
|
||||
expect(fetchAppDetail).toHaveBeenCalledWith('app-id')
|
||||
})
|
||||
|
||||
// Step 5: DSL import triggers pending confirmation
|
||||
expect(mockHandleImportDSL).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Step 6: DSL confirm modal appears and user confirms
|
||||
expect(await screen.findByTestId('dsl-confirm-modal')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByTestId('dsl-confirm'))
|
||||
|
||||
// Step 7: Flow completes successfully
|
||||
await waitFor(() => {
|
||||
expect(mockHandleImportDSLConfirm).toHaveBeenCalledTimes(1)
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading and Empty States', () => {
|
||||
it('should transition from loading to content', () => {
|
||||
// Step 1: Loading state
|
||||
mockIsLoading = true
|
||||
mockExploreData = undefined
|
||||
const { rerender } = render(wrapWithContext())
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
// Step 2: Data loads
|
||||
mockIsLoading = false
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
rerender(wrapWithContext())
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Permission-Based Behavior', () => {
|
||||
it('should hide add-to-workspace button when user has no edit permission', () => {
|
||||
renderWithContext(false)
|
||||
|
||||
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show add-to-workspace button when user has edit permission', () => {
|
||||
renderWithContext(true)
|
||||
|
||||
expect(screen.getAllByText('explore.appCard.addToWorkspace').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
/**
|
||||
* Integration test: Installed App Flow
|
||||
*
|
||||
* Tests the end-to-end user flow of installed apps: sidebar navigation,
|
||||
* mode-based routing (Chat / Completion / Workflow), and lifecycle
|
||||
* operations (pin/unpin, delete).
|
||||
*/
|
||||
import type { Mock } from 'vitest'
|
||||
import type { InstalledApp as InstalledAppModel } from '@/models/explore'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import InstalledApp from '@/app/components/explore/installed-app'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
// Mock external dependencies
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: vi.fn(),
|
||||
createContext: vi.fn(() => ({})),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/web-app-context', () => ({
|
||||
useWebAppStore: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledAppAccessModeByAppId: vi.fn(),
|
||||
useGetInstalledAppParams: vi.fn(),
|
||||
useGetInstalledAppMeta: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/share/text-generation', () => ({
|
||||
default: ({ isWorkflow }: { isWorkflow?: boolean }) => (
|
||||
<div data-testid="text-generation-app">
|
||||
Text Generation
|
||||
{isWorkflow && ' (Workflow)'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat-with-history', () => ({
|
||||
default: ({ installedAppInfo }: { installedAppInfo?: InstalledAppModel }) => (
|
||||
<div data-testid="chat-with-history">
|
||||
Chat -
|
||||
{' '}
|
||||
{installedAppInfo?.app.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Installed App Flow', () => {
|
||||
const mockUpdateAppInfo = vi.fn()
|
||||
const mockUpdateWebAppAccessMode = vi.fn()
|
||||
const mockUpdateAppParams = vi.fn()
|
||||
const mockUpdateWebAppMeta = vi.fn()
|
||||
const mockUpdateUserCanAccessApp = vi.fn()
|
||||
|
||||
const createInstalledApp = (mode: AppModeEnum = AppModeEnum.CHAT): InstalledAppModel => ({
|
||||
id: 'installed-app-1',
|
||||
app: {
|
||||
id: 'real-app-id',
|
||||
name: 'Integration Test App',
|
||||
mode,
|
||||
icon_type: 'emoji',
|
||||
icon: '🧪',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: '',
|
||||
description: 'Test app for integration',
|
||||
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: {},
|
||||
}
|
||||
|
||||
type MockOverrides = {
|
||||
context?: { installedApps?: InstalledAppModel[], isFetchingInstalledApps?: boolean }
|
||||
accessMode?: { isFetching?: boolean, data?: unknown, error?: unknown }
|
||||
params?: { isFetching?: boolean, data?: unknown, error?: unknown }
|
||||
meta?: { isFetching?: boolean, data?: unknown, error?: unknown }
|
||||
userAccess?: { data?: unknown, error?: unknown }
|
||||
}
|
||||
|
||||
const setupDefaultMocks = (app?: InstalledAppModel, overrides: MockOverrides = {}) => {
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: app ? [app] : [],
|
||||
isFetchingInstalledApps: false,
|
||||
...overrides.context,
|
||||
})
|
||||
|
||||
;(useWebAppStore as unknown as Mock).mockImplementation((selector: (state: Record<string, Mock>) => unknown) => {
|
||||
return selector({
|
||||
updateAppInfo: mockUpdateAppInfo,
|
||||
updateWebAppAccessMode: mockUpdateWebAppAccessMode,
|
||||
updateAppParams: mockUpdateAppParams,
|
||||
updateWebAppMeta: mockUpdateWebAppMeta,
|
||||
updateUserCanAccessApp: mockUpdateUserCanAccessApp,
|
||||
})
|
||||
})
|
||||
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: { accessMode: AccessMode.PUBLIC },
|
||||
error: null,
|
||||
...overrides.accessMode,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppParams as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: mockAppParams,
|
||||
error: null,
|
||||
...overrides.params,
|
||||
})
|
||||
|
||||
;(useGetInstalledAppMeta as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: { tool_icons: {} },
|
||||
error: null,
|
||||
...overrides.meta,
|
||||
})
|
||||
|
||||
;(useGetUserCanAccessApp as Mock).mockReturnValue({
|
||||
data: { result: true },
|
||||
error: null,
|
||||
...overrides.userAccess,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Mode-Based Routing', () => {
|
||||
it.each([
|
||||
[AppModeEnum.CHAT, 'chat-with-history'],
|
||||
[AppModeEnum.ADVANCED_CHAT, 'chat-with-history'],
|
||||
[AppModeEnum.AGENT_CHAT, 'chat-with-history'],
|
||||
])('should render ChatWithHistory for %s mode', (mode, testId) => {
|
||||
const app = createInstalledApp(mode)
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByTestId(testId)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Integration Test App/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TextGenerationApp for COMPLETION mode', () => {
|
||||
const app = createInstalledApp(AppModeEnum.COMPLETION)
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
|
||||
expect(screen.getByText('Text Generation')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Workflow/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render TextGenerationApp with workflow flag for WORKFLOW mode', () => {
|
||||
const app = createInstalledApp(AppModeEnum.WORKFLOW)
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByTestId('text-generation-app')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Workflow/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data Loading Flow', () => {
|
||||
it('should show loading spinner when params are being fetched', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app, { params: { isFetching: true, data: null } })
|
||||
|
||||
const { container } = render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(container.querySelector('svg.spin-animation')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('chat-with-history')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content when all data is available', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByTestId('chat-with-history')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling Flow', () => {
|
||||
it('should show error state when API fails', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app, { params: { data: null, error: new Error('Network error') } })
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByText(/Network error/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show 404 when app is not found', () => {
|
||||
setupDefaultMocks(undefined, {
|
||||
accessMode: { data: null },
|
||||
params: { data: null },
|
||||
meta: { data: null },
|
||||
userAccess: { data: null },
|
||||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent" />)
|
||||
|
||||
expect(screen.getByText(/404/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show 403 when user has no permission', () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app, { userAccess: { data: { result: false } } })
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
expect(screen.getByText(/403/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('State Synchronization', () => {
|
||||
it('should update all stores when app data is loaded', async () => {
|
||||
const app = createInstalledApp()
|
||||
setupDefaultMocks(app)
|
||||
|
||||
render(<InstalledApp id="installed-app-1" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateAppInfo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
app_id: 'installed-app-1',
|
||||
site: expect.objectContaining({
|
||||
title: 'Integration Test App',
|
||||
icon: '🧪',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mockUpdateAppParams).toHaveBeenCalledWith(mockAppParams)
|
||||
expect(mockUpdateWebAppMeta).toHaveBeenCalledWith({ tool_icons: {} })
|
||||
expect(mockUpdateWebAppAccessMode).toHaveBeenCalledWith(AccessMode.PUBLIC)
|
||||
expect(mockUpdateUserCanAccessApp).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
import type { IExplore } from '@/context/explore-context'
|
||||
/**
|
||||
* Integration test: Sidebar Lifecycle Flow
|
||||
*
|
||||
* Tests the sidebar interactions for installed apps lifecycle:
|
||||
* navigation, pin/unpin ordering, delete confirmation, and
|
||||
* fold/unfold behavior.
|
||||
*/
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import SideBar from '@/app/components/explore/sidebar'
|
||||
import ExploreContext from '@/context/explore-context'
|
||||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockMediaType: string = MediaType.pc
|
||||
const mockSegments = ['apps']
|
||||
const mockPush = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
const mockUninstall = vi.fn()
|
||||
const mockUpdatePinStatus = vi.fn()
|
||||
let mockInstalledApps: InstalledApp[] = []
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSelectedLayoutSegments: () => mockSegments,
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => mockMediaType,
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
pc: 'pc',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useGetInstalledApps: () => ({
|
||||
isFetching: false,
|
||||
data: { installed_apps: mockInstalledApps },
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
useUninstallApp: () => ({
|
||||
mutateAsync: mockUninstall,
|
||||
}),
|
||||
useUpdateAppPinStatus: () => ({
|
||||
mutateAsync: mockUpdatePinStatus,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createInstalledApp = (overrides: Partial<InstalledApp> = {}): InstalledApp => ({
|
||||
id: overrides.id ?? 'app-1',
|
||||
uninstallable: overrides.uninstallable ?? false,
|
||||
is_pinned: overrides.is_pinned ?? false,
|
||||
app: {
|
||||
id: overrides.app?.id ?? 'app-basic-id',
|
||||
mode: overrides.app?.mode ?? AppModeEnum.CHAT,
|
||||
icon_type: overrides.app?.icon_type ?? 'emoji',
|
||||
icon: overrides.app?.icon ?? '🤖',
|
||||
icon_background: overrides.app?.icon_background ?? '#fff',
|
||||
icon_url: overrides.app?.icon_url ?? '',
|
||||
name: overrides.app?.name ?? 'App One',
|
||||
description: overrides.app?.description ?? 'desc',
|
||||
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
|
||||
},
|
||||
})
|
||||
|
||||
const createContextValue = (installedApps: InstalledApp[] = []): IExplore => ({
|
||||
controlUpdateInstalledApps: 0,
|
||||
setControlUpdateInstalledApps: vi.fn(),
|
||||
hasEditPermission: true,
|
||||
installedApps,
|
||||
setInstalledApps: vi.fn(),
|
||||
isFetchingInstalledApps: false,
|
||||
setIsFetchingInstalledApps: vi.fn(),
|
||||
isShowTryAppPanel: false,
|
||||
setShowTryAppPanel: vi.fn(),
|
||||
})
|
||||
|
||||
const renderSidebar = (installedApps: InstalledApp[] = []) => {
|
||||
return render(
|
||||
<ExploreContext.Provider value={createContextValue(installedApps)}>
|
||||
<SideBar controlUpdateInstalledApps={0} />
|
||||
</ExploreContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sidebar Lifecycle Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMediaType = MediaType.pc
|
||||
mockInstalledApps = []
|
||||
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||
})
|
||||
|
||||
describe('Pin / Unpin / Delete Flow', () => {
|
||||
it('should complete pin → unpin cycle for an app', async () => {
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
|
||||
// Step 1: Start with an unpinned app and pin it
|
||||
const unpinnedApp = createInstalledApp({ is_pinned: false })
|
||||
mockInstalledApps = [unpinnedApp]
|
||||
const { unmount } = renderSidebar(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: true })
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
|
||||
// Step 2: Simulate refetch returning pinned state, then unpin
|
||||
unmount()
|
||||
vi.clearAllMocks()
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
|
||||
const pinnedApp = createInstalledApp({ is_pinned: true })
|
||||
mockInstalledApps = [pinnedApp]
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.unpin'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-1', isPinned: false })
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should complete the delete flow with confirmation', async () => {
|
||||
const app = createInstalledApp()
|
||||
mockInstalledApps = [app]
|
||||
mockUninstall.mockResolvedValue(undefined)
|
||||
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
// Step 1: Open operation menu and click delete
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
// Step 2: Confirm dialog appears
|
||||
expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument()
|
||||
|
||||
// Step 3: Confirm deletion
|
||||
fireEvent.click(screen.getByText('common.operation.confirm'))
|
||||
|
||||
// Step 4: Uninstall API called and success toast shown
|
||||
await waitFor(() => {
|
||||
expect(mockUninstall).toHaveBeenCalledWith('app-1')
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'success',
|
||||
message: 'common.api.remove',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should cancel deletion when user clicks cancel', async () => {
|
||||
const app = createInstalledApp()
|
||||
mockInstalledApps = [app]
|
||||
|
||||
renderSidebar(mockInstalledApps)
|
||||
|
||||
// Open delete flow
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
// Cancel the deletion
|
||||
fireEvent.click(await screen.findByText('common.operation.cancel'))
|
||||
|
||||
// Uninstall should not be called
|
||||
expect(mockUninstall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multi-App Ordering', () => {
|
||||
it('should display pinned apps before unpinned apps with divider', () => {
|
||||
mockInstalledApps = [
|
||||
createInstalledApp({ id: 'pinned-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned App' } }),
|
||||
createInstalledApp({ id: 'unpinned-1', is_pinned: false, app: { ...createInstalledApp().app, name: 'Regular App' } }),
|
||||
]
|
||||
|
||||
const { container } = renderSidebar(mockInstalledApps)
|
||||
|
||||
// Both apps are rendered
|
||||
const pinnedApp = screen.getByText('Pinned App')
|
||||
const regularApp = screen.getByText('Regular App')
|
||||
expect(pinnedApp).toBeInTheDocument()
|
||||
expect(regularApp).toBeInTheDocument()
|
||||
|
||||
// Pinned app appears before unpinned app in the DOM
|
||||
const pinnedItem = pinnedApp.closest('[class*="rounded-lg"]')!
|
||||
const regularItem = regularApp.closest('[class*="rounded-lg"]')!
|
||||
expect(pinnedItem.compareDocumentPosition(regularItem) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
|
||||
// Divider is rendered between pinned and unpinned sections
|
||||
const divider = container.querySelector('[class*="bg-divider-regular"]')
|
||||
expect(divider).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('should show NoApps component when no apps are installed on desktop', () => {
|
||||
mockMediaType = MediaType.pc
|
||||
renderSidebar([])
|
||||
|
||||
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide NoApps on mobile', () => {
|
||||
mockMediaType = MediaType.mobile
|
||||
renderSidebar([])
|
||||
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { AppCategory } from '@/models/explore'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Category from './category'
|
||||
import Category from '../category'
|
||||
|
||||
describe('Category', () => {
|
||||
const allCategoriesEn = 'Recommended'
|
||||
|
|
@ -19,59 +19,44 @@ describe('Category', () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Rendering: basic categories and all-categories button.
|
||||
describe('Rendering', () => {
|
||||
it('should render all categories item and translated categories', () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('explore.apps.allCategories')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.category.Writing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render allCategoriesEn again inside the category list', () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
|
||||
// Assert
|
||||
const recommendedItems = screen.getAllByText('explore.apps.allCategories')
|
||||
expect(recommendedItems).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Props: clicking items triggers onChange.
|
||||
describe('Props', () => {
|
||||
it('should call onChange with category value when category item is clicked', () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('explore.category.Writing'))
|
||||
|
||||
// Assert
|
||||
expect(props.onChange).toHaveBeenCalledWith('Writing')
|
||||
})
|
||||
|
||||
it('should call onChange with allCategoriesEn when all categories is clicked', () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent({ value: 'Writing' })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('explore.apps.allCategories'))
|
||||
|
||||
// Assert
|
||||
expect(props.onChange).toHaveBeenCalledWith(allCategoriesEn)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: handle values not in the list.
|
||||
describe('Edge Cases', () => {
|
||||
it('should treat unknown value as all categories selection', () => {
|
||||
// Arrange
|
||||
renderComponent({ value: 'Unknown' })
|
||||
|
||||
// Assert
|
||||
const allCategoriesItem = screen.getByText('explore.apps.allCategories')
|
||||
expect(allCategoriesItem.className).toContain('bg-components-main-nav-nav-button-bg-active')
|
||||
})
|
||||
|
|
@ -6,7 +6,7 @@ import ExploreContext from '@/context/explore-context'
|
|||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import Explore from './index'
|
||||
import Explore from '../index'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
|
|
@ -65,10 +65,8 @@ describe('Explore', () => {
|
|||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: provides ExploreContext and children.
|
||||
describe('Rendering', () => {
|
||||
it('should render children and provide edit permission from members role', async () => {
|
||||
// Arrange
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
|
|
@ -79,57 +77,48 @@ describe('Explore', () => {
|
|||
},
|
||||
})
|
||||
|
||||
// Act
|
||||
render((
|
||||
<Explore>
|
||||
<ContextReader />
|
||||
</Explore>
|
||||
))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('edit-yes')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Effects: set document title and redirect dataset operators.
|
||||
describe('Effects', () => {
|
||||
it('should set document title on render', () => {
|
||||
// Arrange
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: false,
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
|
||||
|
||||
// Act
|
||||
render((
|
||||
<Explore>
|
||||
<div>child</div>
|
||||
</Explore>
|
||||
))
|
||||
|
||||
// Assert
|
||||
expect(useDocumentTitle).toHaveBeenCalledWith('common.menus.explore')
|
||||
})
|
||||
|
||||
it('should redirect dataset operators to /datasets', async () => {
|
||||
// Arrange
|
||||
; (useAppContext as Mock).mockReturnValue({
|
||||
userProfile: { id: 'user-1' },
|
||||
isCurrentWorkspaceDatasetOperator: true,
|
||||
});
|
||||
(useMembers as Mock).mockReturnValue({ data: { accounts: [] } })
|
||||
|
||||
// Act
|
||||
render((
|
||||
<Explore>
|
||||
<div>child</div>
|
||||
</Explore>
|
||||
))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
import type { AppCardProps } from '../index'
|
||||
import type { App } from '@/models/explore'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from '../index'
|
||||
|
||||
vi.mock('../../../app/type-selector', () => ({
|
||||
AppTypeIcon: ({ type }: { type: string }) => <div data-testid="app-type-icon">{type}</div>,
|
||||
}))
|
||||
|
||||
const createApp = (overrides?: Partial<App>): App => ({
|
||||
can_trial: true,
|
||||
app_id: 'app-id',
|
||||
description: 'App description',
|
||||
copyright: '2024',
|
||||
privacy_policy: null,
|
||||
custom_disclaimer: null,
|
||||
category: 'Assistant',
|
||||
position: 1,
|
||||
is_listed: true,
|
||||
install_count: 0,
|
||||
installed: false,
|
||||
editable: true,
|
||||
is_agent: false,
|
||||
...overrides,
|
||||
app: {
|
||||
id: 'id-1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon_type: null,
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
icon_url: '',
|
||||
name: 'Sample App',
|
||||
description: 'App description',
|
||||
use_icon_as_answer_icon: false,
|
||||
...overrides?.app,
|
||||
},
|
||||
})
|
||||
|
||||
describe('AppCard', () => {
|
||||
const onCreate = vi.fn()
|
||||
|
||||
const renderComponent = (props?: Partial<AppCardProps>) => {
|
||||
const mergedProps: AppCardProps = {
|
||||
app: createApp(),
|
||||
canCreate: false,
|
||||
onCreate,
|
||||
isExplore: false,
|
||||
...props,
|
||||
}
|
||||
return render(<AppCard {...mergedProps} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render app name and description', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(screen.getByText('Sample App')).toBeInTheDocument()
|
||||
expect(screen.getByText('App description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[AppModeEnum.CHAT, 'APP.TYPES.CHATBOT'],
|
||||
[AppModeEnum.ADVANCED_CHAT, 'APP.TYPES.ADVANCED'],
|
||||
[AppModeEnum.AGENT_CHAT, 'APP.TYPES.AGENT'],
|
||||
[AppModeEnum.WORKFLOW, 'APP.TYPES.WORKFLOW'],
|
||||
[AppModeEnum.COMPLETION, 'APP.TYPES.COMPLETION'],
|
||||
])('should render correct mode label for %s mode', (mode, label) => {
|
||||
renderComponent({ app: createApp({ app: { ...createApp().app, mode } }) })
|
||||
|
||||
expect(screen.getByText(label)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-type-icon')).toHaveTextContent(mode)
|
||||
})
|
||||
|
||||
it('should render description in a truncatable container', () => {
|
||||
renderComponent({ app: createApp({ description: 'Very long description text' }) })
|
||||
|
||||
const descWrapper = screen.getByText('Very long description text')
|
||||
expect(descWrapper).toHaveClass('line-clamp-4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should show create button in explore mode and trigger action', () => {
|
||||
renderComponent({
|
||||
app: createApp({ app: { ...createApp().app, mode: AppModeEnum.WORKFLOW } }),
|
||||
canCreate: true,
|
||||
isExplore: true,
|
||||
})
|
||||
|
||||
const button = screen.getByText('explore.appCard.addToWorkspace')
|
||||
expect(button).toBeInTheDocument()
|
||||
fireEvent.click(button)
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should render try button in explore mode', () => {
|
||||
renderComponent({ canCreate: true, isExplore: true })
|
||||
|
||||
expect(screen.getByText('explore.appCard.try')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Props', () => {
|
||||
it('should hide action buttons when not in explore mode', () => {
|
||||
renderComponent({ canCreate: true, isExplore: false })
|
||||
|
||||
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.appCard.try')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide create button when canCreate is false', () => {
|
||||
renderComponent({ canCreate: false, isExplore: true })
|
||||
|
||||
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should truncate long app name with title attribute', () => {
|
||||
const longName = 'A Very Long Application Name That Should Be Truncated'
|
||||
renderComponent({ app: createApp({ app: { ...createApp().app, name: longName } }) })
|
||||
|
||||
const nameElement = screen.getByText(longName)
|
||||
expect(nameElement).toHaveAttribute('title', longName)
|
||||
expect(nameElement).toHaveClass('truncate')
|
||||
})
|
||||
|
||||
it('should render with empty description', () => {
|
||||
renderComponent({ app: createApp({ description: '' }) })
|
||||
|
||||
expect(screen.getByText('Sample App')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import type { AppCardProps } from './index'
|
||||
import type { App } from '@/models/explore'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppCard from './index'
|
||||
|
||||
vi.mock('../../app/type-selector', () => ({
|
||||
AppTypeIcon: ({ type }: any) => <div data-testid="app-type-icon">{type}</div>,
|
||||
}))
|
||||
|
||||
const createApp = (overrides?: Partial<App>): App => ({
|
||||
can_trial: true,
|
||||
app_id: 'app-id',
|
||||
description: 'App description',
|
||||
copyright: '2024',
|
||||
privacy_policy: null,
|
||||
custom_disclaimer: null,
|
||||
category: 'Assistant',
|
||||
position: 1,
|
||||
is_listed: true,
|
||||
install_count: 0,
|
||||
installed: false,
|
||||
editable: true,
|
||||
is_agent: false,
|
||||
...overrides,
|
||||
app: {
|
||||
id: 'id-1',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon_type: null,
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
icon_url: '',
|
||||
name: 'Sample App',
|
||||
description: 'App description',
|
||||
use_icon_as_answer_icon: false,
|
||||
...overrides?.app,
|
||||
},
|
||||
})
|
||||
|
||||
describe('AppCard', () => {
|
||||
const onCreate = vi.fn()
|
||||
|
||||
const renderComponent = (props?: Partial<AppCardProps>) => {
|
||||
const mergedProps: AppCardProps = {
|
||||
app: createApp(),
|
||||
canCreate: false,
|
||||
onCreate,
|
||||
isExplore: false,
|
||||
...props,
|
||||
}
|
||||
return render(<AppCard {...mergedProps} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render app info with correct mode label when mode is CHAT', () => {
|
||||
renderComponent({ app: createApp({ app: { ...createApp().app, mode: AppModeEnum.CHAT } }) })
|
||||
|
||||
expect(screen.getByText('Sample App')).toBeInTheDocument()
|
||||
expect(screen.getByText('App description')).toBeInTheDocument()
|
||||
expect(screen.getByText('APP.TYPES.CHATBOT')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-type-icon')).toHaveTextContent(AppModeEnum.CHAT)
|
||||
})
|
||||
|
||||
it('should show create button in explore mode and trigger action', () => {
|
||||
renderComponent({
|
||||
app: createApp({ app: { ...createApp().app, mode: AppModeEnum.WORKFLOW } }),
|
||||
canCreate: true,
|
||||
isExplore: true,
|
||||
})
|
||||
|
||||
const button = screen.getByText('explore.appCard.addToWorkspace')
|
||||
expect(button).toBeInTheDocument()
|
||||
fireEvent.click(button)
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText('APP.TYPES.WORKFLOW')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide create button when not allowed', () => {
|
||||
renderComponent({ canCreate: false, isExplore: true })
|
||||
|
||||
expect(screen.queryByText('explore.appCard.addToWorkspace')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -5,7 +5,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|||
import ExploreContext from '@/context/explore-context'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import AppList from './index'
|
||||
import AppList from '../index'
|
||||
|
||||
const allCategoriesEn = 'explore.apps.allCategories:{"lng":"en"}'
|
||||
let mockTabValue = allCategoriesEn
|
||||
|
|
@ -150,70 +150,55 @@ describe('AppList', () => {
|
|||
mockIsError = false
|
||||
})
|
||||
|
||||
// Rendering: show loading when categories are not ready.
|
||||
describe('Rendering', () => {
|
||||
it('should render loading when the query is loading', () => {
|
||||
// Arrange
|
||||
mockExploreData = undefined
|
||||
mockIsLoading = true
|
||||
|
||||
// Act
|
||||
renderWithContext()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when data is available', () => {
|
||||
// Arrange
|
||||
mockExploreData = {
|
||||
categories: ['Writing', 'Translate'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
|
||||
}
|
||||
|
||||
// Act
|
||||
renderWithContext()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: category selection filters the list.
|
||||
describe('Props', () => {
|
||||
it('should filter apps by selected category', () => {
|
||||
// Arrange
|
||||
mockTabValue = 'Writing'
|
||||
mockExploreData = {
|
||||
categories: ['Writing', 'Translate'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Beta' }, category: 'Translate' })],
|
||||
}
|
||||
|
||||
// Act
|
||||
renderWithContext()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: search and create flow.
|
||||
describe('User Interactions', () => {
|
||||
it('should filter apps by search keywords', async () => {
|
||||
// Arrange
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
|
||||
}
|
||||
renderWithContext()
|
||||
|
||||
// Act
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'gam' } })
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Alpha')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Gamma')).toBeInTheDocument()
|
||||
|
|
@ -221,7 +206,6 @@ describe('AppList', () => {
|
|||
})
|
||||
|
||||
it('should handle create flow and confirm DSL when pending', async () => {
|
||||
// Arrange
|
||||
const onSuccess = vi.fn()
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
|
|
@ -235,12 +219,10 @@ describe('AppList', () => {
|
|||
options.onSuccess?.()
|
||||
})
|
||||
|
||||
// Act
|
||||
renderWithContext(true, onSuccess)
|
||||
fireEvent.click(screen.getByText('explore.appCard.addToWorkspace'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-create'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(fetchAppDetail).toHaveBeenCalledWith('app-basic-id')
|
||||
})
|
||||
|
|
@ -255,17 +237,14 @@ describe('AppList', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// Edge cases: handle clearing search keywords.
|
||||
describe('Edge Cases', () => {
|
||||
it('should reset search results when clear icon is clicked', async () => {
|
||||
// Arrange
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp(), createApp({ app_id: 'app-2', app: { ...createApp().app, name: 'Gamma' } })],
|
||||
}
|
||||
renderWithContext()
|
||||
|
||||
// Act
|
||||
const input = screen.getByPlaceholderText('common.operation.search')
|
||||
fireEvent.change(input, { target: { value: 'gam' } })
|
||||
await waitFor(() => {
|
||||
|
|
@ -274,7 +253,6 @@ describe('AppList', () => {
|
|||
|
||||
fireEvent.click(screen.getByTestId('input-clear'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Gamma')).toBeInTheDocument()
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { Banner } from '@/models/app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BannerItem } from './banner-item'
|
||||
import { BannerItem } from '../banner-item'
|
||||
|
||||
const mockScrollTo = vi.fn()
|
||||
const mockSlideNodes = vi.fn()
|
||||
|
|
@ -16,17 +16,6 @@ vi.mock('@/app/components/base/carousel', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'banner.viewMore': 'View More',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const createMockBanner = (overrides: Partial<Banner> = {}): Banner => ({
|
||||
id: 'banner-1',
|
||||
status: 'enabled',
|
||||
|
|
@ -40,14 +29,11 @@ const createMockBanner = (overrides: Partial<Banner> = {}): Banner => ({
|
|||
...overrides,
|
||||
} as Banner)
|
||||
|
||||
// Mock ResizeObserver methods declared at module level and initialized
|
||||
const mockResizeObserverObserve = vi.fn()
|
||||
const mockResizeObserverDisconnect = vi.fn()
|
||||
|
||||
// Create mock class outside of describe block for proper hoisting
|
||||
class MockResizeObserver {
|
||||
constructor(_callback: ResizeObserverCallback) {
|
||||
// Store callback if needed
|
||||
}
|
||||
|
||||
observe(...args: Parameters<ResizeObserver['observe']>) {
|
||||
|
|
@ -59,7 +45,6 @@ class MockResizeObserver {
|
|||
}
|
||||
|
||||
unobserve() {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +57,6 @@ describe('BannerItem', () => {
|
|||
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
|
||||
// Mock window.innerWidth for responsive tests
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
|
|
@ -147,7 +131,7 @@ describe('BannerItem', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('View More')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -257,7 +241,6 @@ describe('BannerItem', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Component should render without issues
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
@ -271,7 +254,6 @@ describe('BannerItem', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Component should render with isPaused
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -320,7 +302,6 @@ describe('BannerItem', () => {
|
|||
})
|
||||
|
||||
it('sets maxWidth when window width is below breakpoint', () => {
|
||||
// Set window width below RESPONSIVE_BREAKPOINT (1200)
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
|
|
@ -335,12 +316,10 @@ describe('BannerItem', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Component should render and apply responsive styles
|
||||
expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies responsive styles when below breakpoint', () => {
|
||||
// Set window width below RESPONSIVE_BREAKPOINT (1200)
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
|
|
@ -355,8 +334,7 @@ describe('BannerItem', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// The component should render even with responsive mode
|
||||
expect(screen.getByText('View More')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -432,8 +410,6 @@ describe('BannerItem', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// With selectedIndex=0 and 3 slides, nextIndex should be 1
|
||||
// The second indicator button should show the "next slide" state
|
||||
const buttons = screen.getAllByRole('button')
|
||||
expect(buttons).toHaveLength(3)
|
||||
})
|
||||
|
|
@ -3,7 +3,7 @@ import type { Banner as BannerType } from '@/models/app'
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Banner from './banner'
|
||||
import Banner from '../banner'
|
||||
|
||||
const mockUseGetBanners = vi.fn()
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ vi.mock('@/app/components/base/carousel', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./banner-item', () => ({
|
||||
vi.mock('../banner-item', () => ({
|
||||
BannerItem: ({ banner, autoplayDelay, isPaused }: {
|
||||
banner: BannerType
|
||||
autoplayDelay: number
|
||||
|
|
@ -105,7 +105,6 @@ describe('Banner', () => {
|
|||
|
||||
render(<Banner />)
|
||||
|
||||
// Loading component renders a spinner
|
||||
const loadingWrapper = document.querySelector('[style*="min-height"]')
|
||||
expect(loadingWrapper).toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -266,7 +265,6 @@ describe('Banner', () => {
|
|||
|
||||
const carousel = screen.getByTestId('carousel')
|
||||
|
||||
// Enter and then leave
|
||||
fireEvent.mouseEnter(carousel)
|
||||
fireEvent.mouseLeave(carousel)
|
||||
|
||||
|
|
@ -285,7 +283,6 @@ describe('Banner', () => {
|
|||
|
||||
render(<Banner />)
|
||||
|
||||
// Trigger resize event
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
|
@ -303,12 +300,10 @@ describe('Banner', () => {
|
|||
|
||||
render(<Banner />)
|
||||
|
||||
// Trigger resize event
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
||||
// Wait for debounce delay (50ms)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50)
|
||||
})
|
||||
|
|
@ -326,31 +321,25 @@ describe('Banner', () => {
|
|||
|
||||
render(<Banner />)
|
||||
|
||||
// Trigger first resize event
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
||||
// Wait partial time
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30)
|
||||
})
|
||||
|
||||
// Trigger second resize event
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
||||
// Wait another 30ms (total 60ms from second resize but only 30ms after)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30)
|
||||
})
|
||||
|
||||
// Should still be paused (debounce resets)
|
||||
let bannerItem = screen.getByTestId('banner-item')
|
||||
expect(bannerItem).toHaveAttribute('data-is-paused', 'true')
|
||||
|
||||
// Wait remaining time
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(20)
|
||||
})
|
||||
|
|
@ -388,7 +377,6 @@ describe('Banner', () => {
|
|||
|
||||
const { unmount } = render(<Banner />)
|
||||
|
||||
// Trigger resize to create timer
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
|
|
@ -462,10 +450,8 @@ describe('Banner', () => {
|
|||
|
||||
const { rerender } = render(<Banner />)
|
||||
|
||||
// Re-render with same props
|
||||
rerender(<Banner />)
|
||||
|
||||
// Component should still be present (memo doesn't break rendering)
|
||||
expect(screen.getByTestId('carousel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IndicatorButton } from './indicator-button'
|
||||
import { IndicatorButton } from '../indicator-button'
|
||||
|
||||
describe('IndicatorButton', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -164,7 +164,6 @@ describe('IndicatorButton', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Check for conic-gradient style which indicates progress indicator
|
||||
const progressIndicator = container.querySelector('[style*="conic-gradient"]')
|
||||
expect(progressIndicator).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -221,10 +220,8 @@ describe('IndicatorButton', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Initially no progress indicator
|
||||
expect(container.querySelector('[style*="conic-gradient"]')).not.toBeInTheDocument()
|
||||
|
||||
// Rerender with isNextSlide=true
|
||||
rerender(
|
||||
<IndicatorButton
|
||||
index={1}
|
||||
|
|
@ -237,7 +234,6 @@ describe('IndicatorButton', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Now progress indicator should be visible
|
||||
expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
@ -255,11 +251,9 @@ describe('IndicatorButton', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Progress indicator should be present
|
||||
const progressIndicator = container.querySelector('[style*="conic-gradient"]')
|
||||
expect(progressIndicator).toBeInTheDocument()
|
||||
|
||||
// Rerender with new resetKey - this should reset the progress animation
|
||||
rerender(
|
||||
<IndicatorButton
|
||||
index={1}
|
||||
|
|
@ -273,7 +267,6 @@ describe('IndicatorButton', () => {
|
|||
)
|
||||
|
||||
const newProgressIndicator = container.querySelector('[style*="conic-gradient"]')
|
||||
// The progress indicator should still be present after reset
|
||||
expect(newProgressIndicator).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
@ -293,8 +286,6 @@ describe('IndicatorButton', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// The component should still render but animation should be paused
|
||||
// requestAnimationFrame might still be called for polling but progress won't update
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
mockRequestAnimationFrame.mockRestore()
|
||||
})
|
||||
|
|
@ -315,7 +306,6 @@ describe('IndicatorButton', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Trigger animation frame
|
||||
act(() => {
|
||||
vi.advanceTimersToNextTimer()
|
||||
})
|
||||
|
|
@ -342,12 +332,10 @@ describe('IndicatorButton', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Trigger animation frame
|
||||
act(() => {
|
||||
vi.advanceTimersToNextTimer()
|
||||
})
|
||||
|
||||
// Change isNextSlide to false - this should cancel the animation frame
|
||||
rerender(
|
||||
<IndicatorButton
|
||||
index={1}
|
||||
|
|
@ -368,7 +356,6 @@ describe('IndicatorButton', () => {
|
|||
const mockOnClick = vi.fn()
|
||||
const mockRequestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame')
|
||||
|
||||
// Mock document.hidden to be true
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
|
|
@ -387,10 +374,8 @@ describe('IndicatorButton', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Component should still render
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
|
||||
// Reset document.hidden
|
||||
Object.defineProperty(document, 'hidden', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
|
|
@ -415,7 +400,6 @@ describe('IndicatorButton', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Progress indicator should be visible (animation running)
|
||||
expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,43 +1,12 @@
|
|||
import type { CreateAppModalProps } from './index'
|
||||
import type { CreateAppModalProps } from '../index'
|
||||
import type { UsagePlanInfo } from '@/app/components/billing/type'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { createMockPlan, createMockPlanTotal, createMockPlanUsage } from '@/__mocks__/provider-context'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import CreateAppModal from './index'
|
||||
import CreateAppModal from '../index'
|
||||
|
||||
let mockTranslationOverrides: Record<string, string | undefined> = {}
|
||||
|
||||
vi.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) {
|
||||
const { ns, ...rest } = options
|
||||
const prefix = ns ? `${ns}.` : ''
|
||||
const suffix = Object.keys(rest).length > 0 ? `:${JSON.stringify(rest)}` : ''
|
||||
return `${prefix}${key}${suffix}`
|
||||
}
|
||||
return key
|
||||
},
|
||||
i18n: {
|
||||
language: 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
Trans: ({ children }: { children?: React.ReactNode }) => children,
|
||||
initReactI18next: {
|
||||
type: '3rdParty',
|
||||
init: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Avoid heavy emoji dataset initialization during unit tests.
|
||||
vi.mock('emoji-mart', () => ({
|
||||
init: vi.fn(),
|
||||
SearchIndex: { search: vi.fn().mockResolvedValue([]) },
|
||||
|
|
@ -87,7 +56,7 @@ vi.mock('@/context/provider-context', () => ({
|
|||
|
||||
type ConfirmPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
|
||||
|
||||
const setup = (overrides: Partial<CreateAppModalProps> = {}) => {
|
||||
const setup = async (overrides: Partial<CreateAppModalProps> = {}) => {
|
||||
const onConfirm = vi.fn<(payload: ConfirmPayload) => Promise<void>>().mockResolvedValue(undefined)
|
||||
const onHide = vi.fn()
|
||||
|
||||
|
|
@ -109,7 +78,9 @@ const setup = (overrides: Partial<CreateAppModalProps> = {}) => {
|
|||
...overrides,
|
||||
}
|
||||
|
||||
render(<CreateAppModal {...props} />)
|
||||
await act(async () => {
|
||||
render(<CreateAppModal {...props} />)
|
||||
})
|
||||
return { onConfirm, onHide }
|
||||
}
|
||||
|
||||
|
|
@ -125,25 +96,23 @@ const getAppIconTrigger = (): HTMLElement => {
|
|||
describe('CreateAppModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.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', () => {
|
||||
it('should render create title and actions when creating', () => {
|
||||
setup({ appName: 'My App', isEditModal: false })
|
||||
it('should render create title and actions when creating', async () => {
|
||||
await 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()
|
||||
})
|
||||
|
||||
it('should render edit-only fields when editing a chat app', () => {
|
||||
setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 })
|
||||
it('should render edit-only fields when editing a chat app', async () => {
|
||||
await 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()
|
||||
|
|
@ -151,65 +120,57 @@ describe('CreateAppModal', () => {
|
|||
expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5')
|
||||
})
|
||||
|
||||
it.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', (mode) => {
|
||||
setup({ isEditModal: true, appMode: mode })
|
||||
it.each([AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT])('should render answer icon switch when editing %s app', async (mode) => {
|
||||
await setup({ isEditModal: true, appMode: mode })
|
||||
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render answer icon switch when editing a non-chat app', () => {
|
||||
setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION })
|
||||
it('should not render answer icon switch when editing a non-chat app', async () => {
|
||||
await setup({ isEditModal: true, appMode: AppModeEnum.COMPLETION })
|
||||
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render modal content when hidden', () => {
|
||||
setup({ show: false })
|
||||
it('should not render modal content when hidden', async () => {
|
||||
await setup({ show: false })
|
||||
|
||||
expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Disabled states prevent submission and reflect parent-driven props.
|
||||
describe('Props', () => {
|
||||
it('should disable confirm action when confirmDisabled is true', () => {
|
||||
setup({ confirmDisabled: true })
|
||||
it('should disable confirm action when confirmDisabled is true', async () => {
|
||||
await setup({ confirmDisabled: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable confirm action when appName is empty', () => {
|
||||
setup({ appName: ' ' })
|
||||
it('should disable confirm action when appName is empty', async () => {
|
||||
await setup({ appName: ' ' })
|
||||
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Defensive coverage for falsy input values and translation edge cases.
|
||||
describe('Edge Cases', () => {
|
||||
it('should default description to empty string when appDescription is empty', () => {
|
||||
setup({ appDescription: '' })
|
||||
it('should default description to empty string when appDescription is empty', async () => {
|
||||
await setup({ appDescription: '' })
|
||||
|
||||
expect((screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder') as HTMLTextAreaElement).value).toBe('')
|
||||
})
|
||||
|
||||
it('should fall back to empty placeholders when translations return empty string', () => {
|
||||
mockTranslationOverrides = {
|
||||
'newApp.appNamePlaceholder': '',
|
||||
'newApp.appDescriptionPlaceholder': '',
|
||||
}
|
||||
it('should render i18n key placeholders when translations are available', async () => {
|
||||
await setup()
|
||||
|
||||
setup()
|
||||
|
||||
expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('')
|
||||
expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('')
|
||||
expect((screen.getByDisplayValue('Test App') as HTMLInputElement).placeholder).toBe('app.newApp.appNamePlaceholder')
|
||||
expect((screen.getByDisplayValue('Test description') as HTMLTextAreaElement).placeholder).toBe('app.newApp.appDescriptionPlaceholder')
|
||||
})
|
||||
})
|
||||
|
||||
// The modal should close from user-initiated cancellation actions.
|
||||
describe('User Interactions', () => {
|
||||
it('should call onHide when cancel button is clicked', () => {
|
||||
const { onConfirm, onHide } = setup()
|
||||
it('should call onHide when cancel button is clicked', async () => {
|
||||
const { onConfirm, onHide } = await setup()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
|
|
@ -217,16 +178,16 @@ describe('CreateAppModal', () => {
|
|||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onHide when pressing Escape while visible', () => {
|
||||
const { onHide } = setup()
|
||||
it('should call onHide when pressing Escape while visible', async () => {
|
||||
const { onHide } = await setup()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
|
||||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call onHide when pressing Escape while hidden', () => {
|
||||
const { onHide } = setup({ show: false })
|
||||
it('should not call onHide when pressing Escape while hidden', async () => {
|
||||
const { onHide } = await setup({ show: false })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 })
|
||||
|
||||
|
|
@ -234,34 +195,32 @@ describe('CreateAppModal', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// When billing limits are reached, the modal blocks app creation and shows quota guidance.
|
||||
describe('Quota Gating', () => {
|
||||
it('should show AppsFull and disable create when apps quota is reached', () => {
|
||||
it('should show AppsFull and disable create when apps quota is reached', async () => {
|
||||
mockEnableBilling = true
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
|
||||
setup({ isEditModal: false })
|
||||
await setup({ isEditModal: false })
|
||||
|
||||
expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should allow saving when apps quota is reached in edit mode', () => {
|
||||
it('should allow saving when apps quota is reached in edit mode', async () => {
|
||||
mockEnableBilling = true
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
|
||||
setup({ isEditModal: true })
|
||||
await 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(() => {
|
||||
vi.useFakeTimers()
|
||||
|
|
@ -274,11 +233,11 @@ describe('CreateAppModal', () => {
|
|||
it.each([
|
||||
['meta+enter', { metaKey: true }],
|
||||
['ctrl+enter', { ctrlKey: true }],
|
||||
])('should submit when %s is pressed while visible', (_, modifier) => {
|
||||
const { onConfirm, onHide } = setup()
|
||||
])('should submit when %s is pressed while visible', async (_, modifier) => {
|
||||
const { onConfirm, onHide } = await setup()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, ...modifier })
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -286,11 +245,11 @@ describe('CreateAppModal', () => {
|
|||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not submit when modal is hidden', () => {
|
||||
const { onConfirm, onHide } = setup({ show: false })
|
||||
it('should not submit when modal is hidden', async () => {
|
||||
const { onConfirm, onHide } = await setup({ show: false })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -298,16 +257,16 @@ describe('CreateAppModal', () => {
|
|||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not submit when apps quota is reached in create mode', () => {
|
||||
it('should not submit when apps quota is reached in create mode', async () => {
|
||||
mockEnableBilling = true
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
|
||||
const { onConfirm, onHide } = setup({ isEditModal: false })
|
||||
const { onConfirm, onHide } = await setup({ isEditModal: false })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -315,16 +274,16 @@ describe('CreateAppModal', () => {
|
|||
expect(onHide).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should submit when apps quota is reached in edit mode', () => {
|
||||
it('should submit when apps quota is reached in edit mode', async () => {
|
||||
mockEnableBilling = true
|
||||
mockPlanType = Plan.team
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
|
||||
const { onConfirm, onHide } = setup({ isEditModal: true })
|
||||
const { onConfirm, onHide } = await setup({ isEditModal: true })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -332,11 +291,11 @@ describe('CreateAppModal', () => {
|
|||
expect(onHide).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not submit when name is empty', () => {
|
||||
const { onConfirm, onHide } = setup({ appName: ' ' })
|
||||
it('should not submit when name is empty', async () => {
|
||||
const { onConfirm, onHide } = await setup({ appName: ' ' })
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter', keyCode: 13, metaKey: true })
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -345,10 +304,9 @@ describe('CreateAppModal', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// The app icon picker is a key user flow for customizing metadata.
|
||||
describe('App Icon Picker', () => {
|
||||
it('should open and close the picker when cancel is clicked', () => {
|
||||
setup({
|
||||
it('should open and close the picker when cancel is clicked', async () => {
|
||||
await setup({
|
||||
appIconType: 'image',
|
||||
appIcon: 'file-123',
|
||||
appIconUrl: 'https://example.com/icon.png',
|
||||
|
|
@ -363,10 +321,10 @@ describe('CreateAppModal', () => {
|
|||
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update icon payload when selecting emoji and confirming', () => {
|
||||
it('should update icon payload when selecting emoji and confirming', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { onConfirm } = setup({
|
||||
const { onConfirm } = await setup({
|
||||
appIconType: 'image',
|
||||
appIcon: 'file-123',
|
||||
appIconUrl: 'https://example.com/icon.png',
|
||||
|
|
@ -374,7 +332,6 @@ describe('CreateAppModal', () => {
|
|||
|
||||
fireEvent.click(getAppIconTrigger())
|
||||
|
||||
// Find the emoji grid by locating the category label, then find the clickable emoji wrapper
|
||||
const categoryLabel = screen.getByText('people')
|
||||
const emojiGrid = categoryLabel.nextElementSibling
|
||||
const clickableEmojiWrapper = emojiGrid?.firstElementChild
|
||||
|
|
@ -385,7 +342,7 @@ describe('CreateAppModal', () => {
|
|||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -402,19 +359,17 @@ describe('CreateAppModal', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('should reset emoji icon to initial props when picker is cancelled', () => {
|
||||
it('should reset emoji icon to initial props when picker is cancelled', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { onConfirm } = setup({
|
||||
const { onConfirm } = await setup({
|
||||
appIconType: 'emoji',
|
||||
appIcon: '🤖',
|
||||
appIconBackground: '#FFEAD5',
|
||||
})
|
||||
|
||||
// Open picker, select a new emoji, and confirm
|
||||
fireEvent.click(getAppIconTrigger())
|
||||
|
||||
// Find the emoji grid by locating the category label, then find the clickable emoji wrapper
|
||||
const categoryLabel = screen.getByText('people')
|
||||
const emojiGrid = categoryLabel.nextElementSibling
|
||||
const clickableEmojiWrapper = emojiGrid?.firstElementChild
|
||||
|
|
@ -426,15 +381,13 @@ describe('CreateAppModal', () => {
|
|||
|
||||
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
||||
|
||||
// Open picker again and cancel - should reset to initial props
|
||||
fireEvent.click(getAppIconTrigger())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument()
|
||||
|
||||
// Submit and verify the payload uses the original icon (cancel reverts to props)
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -452,7 +405,6 @@ describe('CreateAppModal', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// Submitting uses a debounced handler and builds a payload from current form state.
|
||||
describe('Submitting', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
|
@ -462,8 +414,8 @@ describe('CreateAppModal', () => {
|
|||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should call onConfirm with emoji payload and hide when create is clicked', () => {
|
||||
const { onConfirm, onHide } = setup({
|
||||
it('should call onConfirm with emoji payload and hide when create is clicked', async () => {
|
||||
const { onConfirm, onHide } = await setup({
|
||||
appName: 'My App',
|
||||
appDescription: 'My description',
|
||||
appIconType: 'emoji',
|
||||
|
|
@ -472,7 +424,7 @@ describe('CreateAppModal', () => {
|
|||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -491,12 +443,12 @@ describe('CreateAppModal', () => {
|
|||
expect(payload).not.toHaveProperty('max_active_requests')
|
||||
})
|
||||
|
||||
it('should include updated description when textarea is changed before submitting', () => {
|
||||
const { onConfirm } = setup({ appDescription: 'Old description' })
|
||||
it('should include updated description when textarea is changed before submitting', async () => {
|
||||
const { onConfirm } = await 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(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -504,8 +456,8 @@ describe('CreateAppModal', () => {
|
|||
expect(onConfirm.mock.calls[0][0]).toMatchObject({ description: 'Updated description' })
|
||||
})
|
||||
|
||||
it('should omit icon_background when submitting with image icon', () => {
|
||||
const { onConfirm } = setup({
|
||||
it('should omit icon_background when submitting with image icon', async () => {
|
||||
const { onConfirm } = await setup({
|
||||
appIconType: 'image',
|
||||
appIcon: 'file-123',
|
||||
appIconUrl: 'https://example.com/icon.png',
|
||||
|
|
@ -513,7 +465,7 @@ describe('CreateAppModal', () => {
|
|||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -525,8 +477,8 @@ describe('CreateAppModal', () => {
|
|||
expect(payload.icon_background).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should include max_active_requests and updated answer icon when saving', () => {
|
||||
const { onConfirm } = setup({
|
||||
it('should include max_active_requests and updated answer icon when saving', async () => {
|
||||
const { onConfirm } = await setup({
|
||||
isEditModal: true,
|
||||
appMode: AppModeEnum.CHAT,
|
||||
appUseIconAsAnswerIcon: false,
|
||||
|
|
@ -537,7 +489,7 @@ describe('CreateAppModal', () => {
|
|||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -548,11 +500,11 @@ describe('CreateAppModal', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('should omit max_active_requests when input is empty', () => {
|
||||
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
|
||||
it('should omit max_active_requests when input is empty', async () => {
|
||||
const { onConfirm } = await setup({ isEditModal: true, max_active_requests: null })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ }))
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -560,12 +512,12 @@ describe('CreateAppModal', () => {
|
|||
expect(payload.max_active_requests).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should omit max_active_requests when input is not a number', () => {
|
||||
const { onConfirm } = setup({ isEditModal: true, max_active_requests: null })
|
||||
it('should omit max_active_requests when input is not a number', async () => {
|
||||
const { onConfirm } = await 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(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
|
|
@ -573,18 +525,18 @@ describe('CreateAppModal', () => {
|
|||
expect(payload.max_active_requests).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should show toast error and not submit when name becomes empty before debounced submit runs', () => {
|
||||
const { onConfirm, onHide } = setup({ appName: 'My App' })
|
||||
it('should show toast error and not submit when name becomes empty before debounced submit runs', async () => {
|
||||
const { onConfirm, onHide } = await setup({ appName: 'My App' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } })
|
||||
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300)
|
||||
})
|
||||
|
||||
expect(screen.getByText('explore.appCustomize.nameRequired')).toBeInTheDocument()
|
||||
act(() => {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(6000)
|
||||
})
|
||||
expect(screen.queryByText('explore.appCustomize.nameRequired')).not.toBeInTheDocument()
|
||||
|
|
@ -8,9 +8,8 @@ import { AccessMode } from '@/models/access-control'
|
|||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams } from '@/service/use-explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import InstalledApp from './index'
|
||||
import InstalledApp from '../index'
|
||||
|
||||
// Mock external dependencies BEFORE imports
|
||||
vi.mock('use-context-selector', () => ({
|
||||
useContext: vi.fn(),
|
||||
createContext: vi.fn(() => ({})),
|
||||
|
|
@ -119,13 +118,11 @@ describe('InstalledApp', () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Mock useContext
|
||||
;(useContext as Mock).mockReturnValue({
|
||||
installedApps: [mockInstalledApp],
|
||||
isFetchingInstalledApps: false,
|
||||
})
|
||||
|
||||
// Mock useWebAppStore
|
||||
;(useWebAppStore as unknown as Mock).mockImplementation((
|
||||
selector: (state: {
|
||||
updateAppInfo: Mock
|
||||
|
|
@ -145,7 +142,6 @@ describe('InstalledApp', () => {
|
|||
return selector(state)
|
||||
})
|
||||
|
||||
// Mock service hooks with default success states
|
||||
;(useGetInstalledAppAccessModeByAppId as Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: mockWebAppAccessMode,
|
||||
|
|
@ -565,7 +561,6 @@ describe('InstalledApp', () => {
|
|||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
// Should find and render the correct app
|
||||
expect(screen.getByText(/Chat With History/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/installed-app-123/)).toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -624,7 +619,6 @@ describe('InstalledApp', () => {
|
|||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
// Error should take precedence over loading
|
||||
expect(screen.getByText(/Some error/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
@ -640,7 +634,6 @@ describe('InstalledApp', () => {
|
|||
})
|
||||
|
||||
render(<InstalledApp id="installed-app-123" />)
|
||||
// Error should take precedence over permission
|
||||
expect(screen.getByText(/Params error/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/403/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -656,7 +649,6 @@ describe('InstalledApp', () => {
|
|||
})
|
||||
|
||||
render(<InstalledApp id="nonexistent-app" />)
|
||||
// Permission should take precedence over 404
|
||||
expect(screen.getByText(/403/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/404/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -673,7 +665,6 @@ describe('InstalledApp', () => {
|
|||
})
|
||||
|
||||
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()
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import ItemOperation from './index'
|
||||
import ItemOperation from '../index'
|
||||
|
||||
describe('ItemOperation', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -20,87 +20,65 @@ describe('ItemOperation', () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Rendering: menu items show after opening.
|
||||
describe('Rendering', () => {
|
||||
it('should render pin and delete actions when menu is open', async () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('explore.sidebar.action.pin')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.sidebar.action.delete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Props: render optional rename action and pinned label text.
|
||||
describe('Props', () => {
|
||||
it('should render rename action when isShowRenameConversation is true', async () => {
|
||||
// Arrange
|
||||
renderComponent({ isShowRenameConversation: true })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('explore.sidebar.action.rename')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render unpin label when isPinned is true', async () => {
|
||||
// Arrange
|
||||
renderComponent({ isPinned: true })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(await screen.findByText('explore.sidebar.action.unpin')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: clicking action items triggers callbacks.
|
||||
describe('User Interactions', () => {
|
||||
it('should call togglePin when clicking pin action', async () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
// Assert
|
||||
expect(props.togglePin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onDelete when clicking delete action', async () => {
|
||||
// Arrange
|
||||
const { props } = renderComponent()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
// Assert
|
||||
expect(props.onDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: menu closes after mouse leave when no hovering state remains.
|
||||
describe('Edge Cases', () => {
|
||||
it('should close the menu when mouse leaves the panel and item is not hovering', async () => {
|
||||
// Arrange
|
||||
renderComponent()
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
const pinText = await screen.findByText('explore.sidebar.action.pin')
|
||||
const menu = pinText.closest('div')?.parentElement as HTMLElement
|
||||
|
||||
// Act
|
||||
fireEvent.mouseEnter(menu)
|
||||
fireEvent.mouseLeave(menu)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('explore.sidebar.action.pin')).not.toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -5,7 +5,7 @@ import Toast from '@/app/components/base/toast'
|
|||
import ExploreContext from '@/context/explore-context'
|
||||
import { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import SideBar from './index'
|
||||
import SideBar from '../index'
|
||||
|
||||
const mockSegments = ['apps']
|
||||
const mockPush = vi.fn()
|
||||
|
|
@ -14,6 +14,7 @@ const mockUninstall = vi.fn()
|
|||
const mockUpdatePinStatus = vi.fn()
|
||||
let mockIsFetching = false
|
||||
let mockInstalledApps: InstalledApp[] = []
|
||||
let mockMediaType: string = MediaType.pc
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSelectedLayoutSegments: () => mockSegments,
|
||||
|
|
@ -23,7 +24,7 @@ vi.mock('next/navigation', () => ({
|
|||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => MediaType.pc,
|
||||
default: () => mockMediaType,
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
|
|
@ -85,53 +86,73 @@ describe('SideBar', () => {
|
|||
vi.clearAllMocks()
|
||||
mockIsFetching = false
|
||||
mockInstalledApps = []
|
||||
mockMediaType = MediaType.pc
|
||||
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
|
||||
})
|
||||
|
||||
// Rendering: show discovery and workspace section.
|
||||
describe('Rendering', () => {
|
||||
it('should render workspace items when installed apps exist', () => {
|
||||
// Arrange
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
it('should render discovery link', () => {
|
||||
renderWithContext()
|
||||
|
||||
// Act
|
||||
expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render workspace items when installed apps exist', () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument()
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Effects: refresh and sync installed apps state.
|
||||
describe('Effects', () => {
|
||||
it('should refetch installed apps on mount', () => {
|
||||
// Arrange
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
it('should render NoApps component when no installed apps on desktop', () => {
|
||||
renderWithContext([])
|
||||
|
||||
// Act
|
||||
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render multiple installed apps', () => {
|
||||
mockInstalledApps = [
|
||||
createInstalledApp({ id: 'app-1', app: { ...createInstalledApp().app, name: 'Alpha' } }),
|
||||
createInstalledApp({ id: 'app-2', app: { ...createInstalledApp().app, name: 'Beta' } }),
|
||||
]
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render divider between pinned and unpinned apps', () => {
|
||||
mockInstalledApps = [
|
||||
createInstalledApp({ id: 'app-1', is_pinned: true, app: { ...createInstalledApp().app, name: 'Pinned' } }),
|
||||
createInstalledApp({ id: 'app-2', is_pinned: false, app: { ...createInstalledApp().app, name: 'Unpinned' } }),
|
||||
]
|
||||
const { container } = renderWithContext(mockInstalledApps)
|
||||
|
||||
const dividers = container.querySelectorAll('[class*="divider"], hr')
|
||||
expect(dividers.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Effects', () => {
|
||||
it('should refetch installed apps on mount', () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
// Assert
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: delete and pin flows.
|
||||
describe('User Interactions', () => {
|
||||
it('should uninstall app and show toast when delete is confirmed', async () => {
|
||||
// Arrange
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
mockUninstall.mockResolvedValue(undefined)
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
fireEvent.click(await screen.findByText('common.operation.confirm'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUninstall).toHaveBeenCalledWith('app-123')
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
|
|
@ -142,16 +163,13 @@ describe('SideBar', () => {
|
|||
})
|
||||
|
||||
it('should update pin status and show toast when pin is clicked', async () => {
|
||||
// Arrange
|
||||
mockInstalledApps = [createInstalledApp({ is_pinned: false })]
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: true })
|
||||
expect(Toast.notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
|
|
@ -160,5 +178,44 @@ describe('SideBar', () => {
|
|||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should unpin an already pinned app', async () => {
|
||||
mockInstalledApps = [createInstalledApp({ is_pinned: true })]
|
||||
mockUpdatePinStatus.mockResolvedValue(undefined)
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.unpin'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdatePinStatus).toHaveBeenCalledWith({ appId: 'app-123', isPinned: false })
|
||||
})
|
||||
})
|
||||
|
||||
it('should open and close confirm dialog for delete', async () => {
|
||||
mockInstalledApps = [createInstalledApp()]
|
||||
renderWithContext(mockInstalledApps)
|
||||
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
expect(await screen.findByText('explore.sidebar.delete.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUninstall).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should hide NoApps and app names on mobile', () => {
|
||||
mockMediaType = MediaType.mobile
|
||||
renderWithContext([])
|
||||
|
||||
expect(screen.queryByText('explore.sidebar.noApps.title')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.sidebar.webApps')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import AppNavItem from './index'
|
||||
import AppNavItem from '../index'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
|
||||
|
|
@ -37,62 +37,46 @@ describe('AppNavItem', () => {
|
|||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering: display app name for desktop and hide for mobile.
|
||||
describe('Rendering', () => {
|
||||
it('should render name and item operation on desktop', () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('My App')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('item-operation-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide name on mobile', () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} isMobile />)
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('My App')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// User interactions: navigation and delete flow.
|
||||
describe('User Interactions', () => {
|
||||
it('should navigate to installed app when item is clicked', () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('My App'))
|
||||
|
||||
// Assert
|
||||
expect(mockPush).toHaveBeenCalledWith('/explore/installed/app-123')
|
||||
})
|
||||
|
||||
it('should call onDelete with app id when delete action is clicked', async () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
|
||||
|
||||
// Assert
|
||||
expect(baseProps.onDelete).toHaveBeenCalledWith('app-123')
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases: hide delete when uninstallable or selected.
|
||||
describe('Edge Cases', () => {
|
||||
it('should not render delete action when app is uninstallable', () => {
|
||||
// Arrange
|
||||
render(<AppNavItem {...baseProps} uninstallable />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByTestId('item-operation-trigger'))
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import { Theme } from '@/types/app'
|
||||
import NoApps from '../index'
|
||||
|
||||
let mockTheme = Theme.light
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: mockTheme }),
|
||||
}))
|
||||
|
||||
describe('NoApps', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTheme = Theme.light
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render title, description and learn-more link', () => {
|
||||
render(<NoApps />)
|
||||
|
||||
expect(screen.getByText('explore.sidebar.noApps.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.sidebar.noApps.description')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.sidebar.noApps.learnMore')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render learn-more as external link with correct href', () => {
|
||||
render(<NoApps />)
|
||||
|
||||
const link = screen.getByText('explore.sidebar.noApps.learnMore')
|
||||
expect(link.tagName).toBe('A')
|
||||
expect(link).toHaveAttribute('href', 'https://docs.dify.ai/use-dify/publish/README')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Theme', () => {
|
||||
it('should apply light theme background class in light mode', () => {
|
||||
mockTheme = Theme.light
|
||||
|
||||
const { container } = render(<NoApps />)
|
||||
const bgDiv = container.querySelector('[class*="bg-contain"]')
|
||||
|
||||
expect(bgDiv).toBeInTheDocument()
|
||||
expect(bgDiv?.className).toContain('light')
|
||||
expect(bgDiv?.className).not.toContain('dark')
|
||||
})
|
||||
|
||||
it('should apply dark theme background class in dark mode', () => {
|
||||
mockTheme = Theme.dark
|
||||
|
||||
const { container } = render(<NoApps />)
|
||||
const bgDiv = container.querySelector('[class*="bg-contain"]')
|
||||
|
||||
expect(bgDiv).toBeInTheDocument()
|
||||
expect(bgDiv?.className).toContain('dark')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,20 +1,8 @@
|
|||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TryApp from './index'
|
||||
import { TypeEnum } from './tab'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'tryApp.tabHeader.try': 'Try',
|
||||
'tryApp.tabHeader.detail': 'Detail',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import TryApp from '../index'
|
||||
import { TypeEnum } from '../tab'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal() as object
|
||||
|
|
@ -30,7 +18,7 @@ vi.mock('@/service/use-try-app', () => ({
|
|||
useGetTryAppInfo: (...args: unknown[]) => mockUseGetTryAppInfo(...args),
|
||||
}))
|
||||
|
||||
vi.mock('./app', () => ({
|
||||
vi.mock('../app', () => ({
|
||||
default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => (
|
||||
<div data-testid="app-component" data-app-id={appId} data-mode={appDetail?.mode}>
|
||||
App Component
|
||||
|
|
@ -38,7 +26,7 @@ vi.mock('./app', () => ({
|
|||
),
|
||||
}))
|
||||
|
||||
vi.mock('./preview', () => ({
|
||||
vi.mock('../preview', () => ({
|
||||
default: ({ appId, appDetail }: { appId: string, appDetail: TryAppInfo }) => (
|
||||
<div data-testid="preview-component" data-app-id={appId} data-mode={appDetail?.mode}>
|
||||
Preview Component
|
||||
|
|
@ -46,7 +34,7 @@ vi.mock('./preview', () => ({
|
|||
),
|
||||
}))
|
||||
|
||||
vi.mock('./app-info', () => ({
|
||||
vi.mock('../app-info', () => ({
|
||||
default: ({
|
||||
appId,
|
||||
appDetail,
|
||||
|
|
@ -141,8 +129,8 @@ describe('TryApp (main index.tsx)', () => {
|
|||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Try')).toBeInTheDocument()
|
||||
expect(screen.getByText('Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -185,7 +173,6 @@ describe('TryApp (main index.tsx)', () => {
|
|||
)
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the close button (the one with RiCloseLine icon)
|
||||
const buttons = document.body.querySelectorAll('button')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
|
@ -203,10 +190,10 @@ describe('TryApp (main index.tsx)', () => {
|
|||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Detail'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="preview-component"]')).toBeInTheDocument()
|
||||
|
|
@ -224,18 +211,16 @@ describe('TryApp (main index.tsx)', () => {
|
|||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// First switch to Detail
|
||||
fireEvent.click(screen.getByText('Detail'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="preview-component"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Then switch back to Try
|
||||
fireEvent.click(screen.getByText('Try'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.body.querySelector('[data-testid="app-component"]')).toBeInTheDocument()
|
||||
|
|
@ -256,7 +241,6 @@ describe('TryApp (main index.tsx)', () => {
|
|||
)
|
||||
|
||||
await waitFor(() => {
|
||||
// Find the button with close icon
|
||||
const buttons = document.body.querySelectorAll('button')
|
||||
const closeButton = Array.from(buttons).find(btn =>
|
||||
btn.querySelector('svg') || btn.className.includes('rounded-[10px]'),
|
||||
|
|
@ -368,10 +352,10 @@ describe('TryApp (main index.tsx)', () => {
|
|||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Detail'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail'))
|
||||
|
||||
await waitFor(() => {
|
||||
const previewComponent = document.body.querySelector('[data-testid="preview-component"]')
|
||||
|
|
@ -1,18 +1,6 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Tab, { TypeEnum } from './tab'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'tryApp.tabHeader.try': 'Try',
|
||||
'tryApp.tabHeader.detail': 'Detail',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import Tab, { TypeEnum } from '../tab'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal() as object
|
||||
|
|
@ -31,23 +19,23 @@ describe('Tab', () => {
|
|||
const mockOnChange = vi.fn()
|
||||
render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByText('Try')).toBeInTheDocument()
|
||||
expect(screen.getByText('Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders tab with DETAIL value selected', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByText('Try')).toBeInTheDocument()
|
||||
expect(screen.getByText('Detail')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onChange when clicking a tab', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Detail'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail'))
|
||||
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.DETAIL)
|
||||
})
|
||||
|
||||
|
|
@ -55,7 +43,7 @@ describe('Tab', () => {
|
|||
const mockOnChange = vi.fn()
|
||||
render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Try'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try'))
|
||||
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.TRY)
|
||||
})
|
||||
|
||||
|
|
@ -1,29 +1,11 @@
|
|||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppInfo from './index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'types.advanced': 'Advanced',
|
||||
'types.chatbot': 'Chatbot',
|
||||
'types.agent': 'Agent',
|
||||
'types.workflow': 'Workflow',
|
||||
'types.completion': 'Completion',
|
||||
'tryApp.createFromSampleApp': 'Create from Sample',
|
||||
'tryApp.category': 'Category',
|
||||
'tryApp.requirements': 'Requirements',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import AppInfo from '../index'
|
||||
|
||||
const mockUseGetRequirements = vi.fn()
|
||||
|
||||
vi.mock('./use-get-requirements', () => ({
|
||||
vi.mock('../use-get-requirements', () => ({
|
||||
default: (...args: unknown[]) => mockUseGetRequirements(...args),
|
||||
}))
|
||||
|
||||
|
|
@ -118,7 +100,7 @@ describe('AppInfo', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('ADVANCED')).toBeInTheDocument()
|
||||
expect(screen.getByText('APP.TYPES.ADVANCED')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays CHATBOT for chat mode', () => {
|
||||
|
|
@ -133,7 +115,7 @@ describe('AppInfo', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('CHATBOT')).toBeInTheDocument()
|
||||
expect(screen.getByText('APP.TYPES.CHATBOT')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays AGENT for agent-chat mode', () => {
|
||||
|
|
@ -148,7 +130,7 @@ describe('AppInfo', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('AGENT')).toBeInTheDocument()
|
||||
expect(screen.getByText('APP.TYPES.AGENT')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays WORKFLOW for workflow mode', () => {
|
||||
|
|
@ -163,7 +145,7 @@ describe('AppInfo', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('WORKFLOW')).toBeInTheDocument()
|
||||
expect(screen.getByText('APP.TYPES.WORKFLOW')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays COMPLETION for completion mode', () => {
|
||||
|
|
@ -178,7 +160,7 @@ describe('AppInfo', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('COMPLETION')).toBeInTheDocument()
|
||||
expect(screen.getByText('APP.TYPES.COMPLETION')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -214,7 +196,6 @@ describe('AppInfo', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Check that there's no element with the description class that has empty content
|
||||
const descriptionElements = container.querySelectorAll('.system-sm-regular.mt-\\[14px\\]')
|
||||
expect(descriptionElements.length).toBe(0)
|
||||
})
|
||||
|
|
@ -233,7 +214,7 @@ describe('AppInfo', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Create from Sample')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.createFromSampleApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onCreate when button is clicked', () => {
|
||||
|
|
@ -248,7 +229,7 @@ describe('AppInfo', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Create from Sample'))
|
||||
fireEvent.click(screen.getByText('explore.tryApp.createFromSampleApp'))
|
||||
expect(mockOnCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -267,7 +248,7 @@ describe('AppInfo', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Category')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.category')).toBeInTheDocument()
|
||||
expect(screen.getByText('AI Assistant')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
@ -283,7 +264,7 @@ describe('AppInfo', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Category')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.tryApp.category')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -307,7 +288,7 @@ describe('AppInfo', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Requirements')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.requirements')).toBeInTheDocument()
|
||||
expect(screen.getByText('OpenAI GPT-4')).toBeInTheDocument()
|
||||
expect(screen.getByText('Google Search')).toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -328,7 +309,7 @@ describe('AppInfo', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Requirements')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.tryApp.requirements')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders requirement icons with correct background image', () => {
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import useGetRequirements from './use-get-requirements'
|
||||
import useGetRequirements from '../use-get-requirements'
|
||||
|
||||
const mockUseGetTryAppFlowPreview = vi.fn()
|
||||
|
||||
|
|
@ -165,7 +165,6 @@ describe('useGetRequirements', () => {
|
|||
useGetRequirements({ appDetail, appId: 'test-app-id' }),
|
||||
)
|
||||
|
||||
// Only model provider should be included, no disabled tools
|
||||
expect(result.current.requirements).toHaveLength(1)
|
||||
expect(result.current.requirements[0].name).toBe('openai')
|
||||
})
|
||||
|
|
@ -1,19 +1,7 @@
|
|||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import TryApp from './chat'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'chat.resetChat': 'Reset Chat',
|
||||
'tryApp.tryInfo': 'This is try mode info',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import TryApp from '../chat'
|
||||
|
||||
const mockRemoveConversationIdInfo = vi.fn()
|
||||
const mockHandleNewConversation = vi.fn()
|
||||
|
|
@ -31,7 +19,7 @@ vi.mock('@/hooks/use-breakpoints', () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../base/chat/embedded-chatbot/theme/theme-context', () => ({
|
||||
vi.mock('../../../../base/chat/embedded-chatbot/theme/theme-context', () => ({
|
||||
useThemeContext: () => ({
|
||||
primaryColor: '#1890ff',
|
||||
}),
|
||||
|
|
@ -146,7 +134,7 @@ describe('TryApp (chat.tsx)', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('This is try mode info')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tryInfo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies className prop', () => {
|
||||
|
|
@ -160,7 +148,6 @@ describe('TryApp (chat.tsx)', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// The component wraps with EmbeddedChatbotContext.Provider, first child is the div with className
|
||||
const innerDiv = container.querySelector('.custom-class')
|
||||
expect(innerDiv).toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -185,7 +172,6 @@ describe('TryApp (chat.tsx)', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Reset button should not be present
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
@ -207,7 +193,6 @@ describe('TryApp (chat.tsx)', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Should have a button (the reset button)
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
|
@ -313,14 +298,12 @@ describe('TryApp (chat.tsx)', () => {
|
|||
/>,
|
||||
)
|
||||
|
||||
// Find and click the hide button on the alert
|
||||
const alertElement = screen.getByText('This is try mode info').closest('[class*="alert"]')?.parentElement
|
||||
const alertElement = screen.getByText('explore.tryApp.tryInfo').closest('[class*="alert"]')?.parentElement
|
||||
const hideButton = alertElement?.querySelector('button, [role="button"], svg')
|
||||
|
||||
if (hideButton) {
|
||||
fireEvent.click(hideButton)
|
||||
// After hiding, the alert should not be visible
|
||||
expect(screen.queryByText('This is try mode info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('explore.tryApp.tryInfo')).not.toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,19 +1,13 @@
|
|||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import TryApp from './index'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
import TryApp from '../index'
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./chat', () => ({
|
||||
vi.mock('../chat', () => ({
|
||||
default: ({ appId, appDetail, className }: { appId: string, appDetail: TryAppInfo, className: string }) => (
|
||||
<div data-testid="chat-component" data-app-id={appId} data-mode={appDetail.mode} className={className}>
|
||||
Chat Component
|
||||
|
|
@ -21,7 +15,7 @@ vi.mock('./chat', () => ({
|
|||
),
|
||||
}))
|
||||
|
||||
vi.mock('./text-generation', () => ({
|
||||
vi.mock('../text-generation', () => ({
|
||||
default: ({
|
||||
appId,
|
||||
className,
|
||||
|
|
@ -1,18 +1,7 @@
|
|||
import type { AppData } from '@/models/share'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import TextGeneration from './text-generation'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'tryApp.tryInfo': 'This is a try app notice',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
import TextGeneration from '../text-generation'
|
||||
|
||||
const mockUpdateAppInfo = vi.fn()
|
||||
const mockUpdateAppParams = vi.fn()
|
||||
|
|
@ -156,7 +145,6 @@ describe('TextGeneration', () => {
|
|||
)
|
||||
|
||||
await waitFor(() => {
|
||||
// Multiple elements may have the title (header and RunOnce mock)
|
||||
const titles = screen.getAllByText('Test App Title')
|
||||
expect(titles.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
|
@ -275,7 +263,6 @@ describe('TextGeneration', () => {
|
|||
|
||||
fireEvent.click(screen.getByTestId('send-button'))
|
||||
|
||||
// The send should work without errors
|
||||
expect(screen.getByTestId('result-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -298,7 +285,7 @@ describe('TextGeneration', () => {
|
|||
fireEvent.click(screen.getByTestId('complete-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('This is a try app notice')).toBeInTheDocument()
|
||||
expect(screen.getByText('explore.tryApp.tryInfo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -384,7 +371,6 @@ describe('TextGeneration', () => {
|
|||
|
||||
fireEvent.click(screen.getByTestId('run-start-button'))
|
||||
|
||||
// Result panel should remain visible
|
||||
expect(screen.getByTestId('result-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -404,10 +390,8 @@ describe('TextGeneration', () => {
|
|||
expect(screen.getByTestId('inputs-change-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Trigger input change which should call setInputs callback
|
||||
fireEvent.click(screen.getByTestId('inputs-change-button'))
|
||||
|
||||
// The component should handle the input change without errors
|
||||
expect(screen.getByTestId('run-once')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -425,7 +409,6 @@ describe('TextGeneration', () => {
|
|||
)
|
||||
|
||||
await waitFor(() => {
|
||||
// Mobile toggle panel should be rendered
|
||||
const togglePanel = container.querySelector('.cursor-grab')
|
||||
expect(togglePanel).toBeInTheDocument()
|
||||
})
|
||||
|
|
@ -447,13 +430,11 @@ describe('TextGeneration', () => {
|
|||
expect(togglePanel).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click to show result panel
|
||||
const toggleParent = container.querySelector('.cursor-grab')?.parentElement
|
||||
if (toggleParent) {
|
||||
fireEvent.click(toggleParent)
|
||||
}
|
||||
|
||||
// Click again to hide result panel
|
||||
await waitFor(() => {
|
||||
const newToggleParent = container.querySelector('.cursor-grab')?.parentElement
|
||||
if (newToggleParent) {
|
||||
|
|
@ -461,7 +442,6 @@ describe('TextGeneration', () => {
|
|||
}
|
||||
})
|
||||
|
||||
// Component should handle both show and hide without errors
|
||||
expect(screen.getByTestId('result-component')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,12 +1,6 @@
|
|||
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import BasicAppPreview from './basic-app-preview'
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
import BasicAppPreview from '../basic-app-preview'
|
||||
|
||||
const mockUseGetTryAppInfo = vi.fn()
|
||||
const mockUseAllToolProviders = vi.fn()
|
||||
|
|
@ -22,7 +16,7 @@ vi.mock('@/service/use-tools', () => ({
|
|||
useAllToolProviders: () => mockUseAllToolProviders(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../header/account-setting/model-provider-page/hooks', () => ({
|
||||
vi.mock('../../../../header/account-setting/model-provider-page/hooks', () => ({
|
||||
useTextGenerationCurrentProviderAndModelAndModelList: (...args: unknown[]) =>
|
||||
mockUseTextGenerationCurrentProviderAndModelAndModelList(...args),
|
||||
}))
|
||||
|
|
@ -518,7 +512,6 @@ describe('BasicAppPreview', () => {
|
|||
|
||||
render(<BasicAppPreview appId="test-app-id" />)
|
||||
|
||||
// Should still render (with default model config)
|
||||
await waitFor(() => {
|
||||
expect(mockUseGetTryAppDataSets).toHaveBeenCalled()
|
||||
})
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import FlowAppPreview from './flow-app-preview'
|
||||
import FlowAppPreview from '../flow-app-preview'
|
||||
|
||||
const mockUseGetTryAppFlowPreview = vi.fn()
|
||||
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import type { TryAppInfo } from '@/service/try-app'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Preview from './index'
|
||||
import Preview from '../index'
|
||||
|
||||
vi.mock('./basic-app-preview', () => ({
|
||||
vi.mock('../basic-app-preview', () => ({
|
||||
default: ({ appId }: { appId: string }) => (
|
||||
<div data-testid="basic-app-preview" data-app-id={appId}>
|
||||
BasicAppPreview
|
||||
|
|
@ -11,7 +11,7 @@ vi.mock('./basic-app-preview', () => ({
|
|||
),
|
||||
}))
|
||||
|
||||
vi.mock('./flow-app-preview', () => ({
|
||||
vi.mock('../flow-app-preview', () => ({
|
||||
default: ({ appId, className }: { appId: string, className?: string }) => (
|
||||
<div data-testid="flow-app-preview" data-app-id={appId} className={className}>
|
||||
FlowAppPreview
|
||||
Loading…
Reference in New Issue