mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 18:05:11 +00:00
test: add integration tests for app card operations, list browsing, and create app flows (#32298)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
459
web/__tests__/apps/app-card-operations-flow.test.tsx
Normal file
459
web/__tests__/apps/app-card-operations-flow.test.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Integration test: App Card Operations Flow
|
||||
*
|
||||
* Tests the end-to-end user flows for app card operations:
|
||||
* - Editing app info
|
||||
* - Duplicating an app
|
||||
* - Deleting an app
|
||||
* - Exporting app DSL
|
||||
* - Navigation on card click
|
||||
* - Access mode icons
|
||||
*/
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppCard from '@/app/components/apps/app-card'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
let mockSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
webapp_auth: { enabled: false },
|
||||
}
|
||||
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
const mockOnPlanInfoChanged = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock headless UI Popover so it renders content without transition
|
||||
vi.mock('@headlessui/react', async () => {
|
||||
const actual = await vi.importActual<typeof import('@headlessui/react')>('@headlessui/react')
|
||||
return {
|
||||
...actual,
|
||||
Popover: ({ children, className }: { children: ((bag: { open: boolean }) => React.ReactNode) | React.ReactNode, className?: string }) => (
|
||||
<div className={className} data-testid="popover-wrapper">
|
||||
{typeof children === 'function' ? children({ open: true }) : children}
|
||||
</div>
|
||||
),
|
||||
PopoverButton: ({ children, className, ref: _ref, ...rest }: Record<string, unknown>) => (
|
||||
<button className={className as string} {...rest}>{children as React.ReactNode}</button>
|
||||
),
|
||||
PopoverPanel: ({ children, className }: { children: ((bag: { close: () => void }) => React.ReactNode) | React.ReactNode, className?: string }) => (
|
||||
<div className={className}>
|
||||
{typeof children === 'function' ? children({ close: vi.fn() }) : children}
|
||||
</div>
|
||||
),
|
||||
Transition: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
|
||||
let Component: React.ComponentType<Record<string, unknown>> | null = null
|
||||
loader().then((mod) => {
|
||||
Component = mod.default as React.ComponentType<Record<string, unknown>>
|
||||
}).catch(() => {})
|
||||
const Wrapper = (props: Record<string, unknown>) => {
|
||||
if (Component)
|
||||
return <Component {...props} />
|
||||
return null
|
||||
}
|
||||
Wrapper.displayName = 'DynamicWrapper'
|
||||
return Wrapper
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = { systemFeatures: mockSystemFeatures }
|
||||
if (typeof selector === 'function')
|
||||
return selector(state)
|
||||
return mockSystemFeatures
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: mockOnPlanInfoChanged,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock the ToastContext used via useContext from use-context-selector
|
||||
vi.mock('use-context-selector', async () => {
|
||||
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
|
||||
return {
|
||||
...actual,
|
||||
useContext: () => ({ notify: mockNotify }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
tagList: [],
|
||||
showTagManagementModal: false,
|
||||
setTagList: vi.fn(),
|
||||
setShowTagManagementModal: vi.fn(),
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
deleteApp: vi.fn().mockResolvedValue({}),
|
||||
updateAppInfo: vi.fn().mockResolvedValue({}),
|
||||
copyApp: vi.fn().mockResolvedValue({ id: 'new-app-id', mode: 'chat' }),
|
||||
exportAppConfig: vi.fn().mockResolvedValue({ data: 'yaml-content' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/explore', () => ({
|
||||
fetchInstalledAppList: vi.fn().mockResolvedValue({ installed_apps: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: vi.fn().mockResolvedValue({ environment_variables: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/access-control', () => ({
|
||||
useGetUserCanAccessApp: () => ({ data: { result: true }, isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock modals loaded via next/dynamic
|
||||
vi.mock('@/app/components/explore/create-app-modal', () => ({
|
||||
default: ({ show, onConfirm, onHide, appName }: Record<string, unknown>) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="edit-app-modal">
|
||||
<span data-testid="modal-app-name">{appName as string}</span>
|
||||
<button
|
||||
data-testid="confirm-edit"
|
||||
onClick={() => (onConfirm as (data: Record<string, unknown>) => void)({
|
||||
name: 'Updated App Name',
|
||||
icon_type: 'emoji',
|
||||
icon: '🔥',
|
||||
icon_background: '#fff',
|
||||
description: 'Updated description',
|
||||
})}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button data-testid="cancel-edit" onClick={onHide as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/duplicate-modal', () => ({
|
||||
default: ({ show, onConfirm, onHide }: Record<string, unknown>) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="duplicate-app-modal">
|
||||
<button
|
||||
data-testid="confirm-duplicate"
|
||||
onClick={() => (onConfirm as (data: Record<string, unknown>) => void)({
|
||||
name: 'Copied App',
|
||||
icon_type: 'emoji',
|
||||
icon: '📋',
|
||||
icon_background: '#fff',
|
||||
})}
|
||||
>
|
||||
Confirm Duplicate
|
||||
</button>
|
||||
<button data-testid="cancel-duplicate" onClick={onHide as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/switch-app-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess }: Record<string, unknown>) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="switch-app-modal">
|
||||
<button data-testid="confirm-switch" onClick={onSuccess as () => void}>Confirm Switch</button>
|
||||
<button data-testid="cancel-switch" onClick={onClose as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/confirm', () => ({
|
||||
default: ({ isShow, onConfirm, onCancel, title }: Record<string, unknown>) => {
|
||||
if (!isShow)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="confirm-delete-modal">
|
||||
<span>{title as string}</span>
|
||||
<button data-testid="confirm-delete" onClick={onConfirm as () => void}>Delete</button>
|
||||
<button data-testid="cancel-delete" onClick={onCancel as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
default: ({ onConfirm, onClose }: Record<string, unknown>) => (
|
||||
<div data-testid="dsl-export-confirm-modal">
|
||||
<button data-testid="export-include" onClick={() => (onConfirm as (include: boolean) => void)(true)}>Include</button>
|
||||
<button data-testid="export-close" onClick={onClose as () => void}>Close</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/app-access-control', () => ({
|
||||
default: ({ onConfirm, onClose }: Record<string, unknown>) => (
|
||||
<div data-testid="access-control-modal">
|
||||
<button data-testid="confirm-access" onClick={onConfirm as () => void}>Confirm</button>
|
||||
<button data-testid="cancel-access" onClick={onClose as () => void}>Cancel</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
id: overrides.id ?? 'app-1',
|
||||
name: overrides.name ?? 'Test Chat App',
|
||||
description: overrides.description ?? 'A chat application',
|
||||
author_name: overrides.author_name ?? 'Test Author',
|
||||
icon_type: overrides.icon_type ?? 'emoji',
|
||||
icon: overrides.icon ?? '🤖',
|
||||
icon_background: overrides.icon_background ?? '#FFEAD5',
|
||||
icon_url: overrides.icon_url ?? null,
|
||||
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
|
||||
mode: overrides.mode ?? AppModeEnum.CHAT,
|
||||
enable_site: overrides.enable_site ?? true,
|
||||
enable_api: overrides.enable_api ?? true,
|
||||
api_rpm: overrides.api_rpm ?? 60,
|
||||
api_rph: overrides.api_rph ?? 3600,
|
||||
is_demo: overrides.is_demo ?? false,
|
||||
model_config: overrides.model_config ?? {} as App['model_config'],
|
||||
app_model_config: overrides.app_model_config ?? {} as App['app_model_config'],
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
updated_at: overrides.updated_at ?? 1700001000,
|
||||
site: overrides.site ?? {} as App['site'],
|
||||
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
|
||||
tags: overrides.tags ?? [],
|
||||
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
|
||||
max_active_requests: overrides.max_active_requests ?? null,
|
||||
})
|
||||
|
||||
const mockOnRefresh = vi.fn()
|
||||
|
||||
const renderAppCard = (app?: Partial<App>) => {
|
||||
return render(<AppCard app={createMockApp(app)} onRefresh={mockOnRefresh} />)
|
||||
}
|
||||
|
||||
describe('App Card Operations Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceEditor = true
|
||||
mockSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
webapp_auth: { enabled: false },
|
||||
}
|
||||
})
|
||||
|
||||
// -- Basic rendering --
|
||||
describe('Card Rendering', () => {
|
||||
it('should render app name and description', () => {
|
||||
renderAppCard({ name: 'My AI Bot', description: 'An intelligent assistant' })
|
||||
|
||||
expect(screen.getByText('My AI Bot')).toBeInTheDocument()
|
||||
expect(screen.getByText('An intelligent assistant')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render author name', () => {
|
||||
renderAppCard({ author_name: 'John Doe' })
|
||||
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should navigate to app config page when card is clicked', () => {
|
||||
renderAppCard({ id: 'app-123', mode: AppModeEnum.CHAT })
|
||||
|
||||
const card = screen.getByText('Test Chat App').closest('[class*="cursor-pointer"]')
|
||||
if (card)
|
||||
fireEvent.click(card)
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-123/configuration')
|
||||
})
|
||||
|
||||
it('should navigate to workflow page for workflow apps', () => {
|
||||
renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' })
|
||||
|
||||
const card = screen.getByText('WF App').closest('[class*="cursor-pointer"]')
|
||||
if (card)
|
||||
fireEvent.click(card)
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith('/app/app-wf/workflow')
|
||||
})
|
||||
})
|
||||
|
||||
// -- Delete flow --
|
||||
describe('Delete App Flow', () => {
|
||||
it('should show delete confirmation and call API on confirm', async () => {
|
||||
renderAppCard({ id: 'app-to-delete', name: 'Deletable App' })
|
||||
|
||||
// Find and click the more button (popover trigger)
|
||||
const moreIcons = document.querySelectorAll('svg')
|
||||
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
|
||||
|
||||
if (moreFill) {
|
||||
const btn = moreFill.closest('[class*="cursor-pointer"]')
|
||||
if (btn)
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => {
|
||||
const deleteBtn = screen.queryByText('common.operation.delete')
|
||||
if (deleteBtn)
|
||||
fireEvent.click(deleteBtn)
|
||||
})
|
||||
|
||||
const confirmBtn = screen.queryByTestId('confirm-delete')
|
||||
if (confirmBtn) {
|
||||
fireEvent.click(confirmBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteApp).toHaveBeenCalledWith('app-to-delete')
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// -- Edit flow --
|
||||
describe('Edit App Flow', () => {
|
||||
it('should open edit modal and call updateAppInfo on confirm', async () => {
|
||||
renderAppCard({ id: 'app-edit', name: 'Editable App' })
|
||||
|
||||
const moreIcons = document.querySelectorAll('svg')
|
||||
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
|
||||
|
||||
if (moreFill) {
|
||||
const btn = moreFill.closest('[class*="cursor-pointer"]')
|
||||
if (btn)
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => {
|
||||
const editBtn = screen.queryByText('app.editApp')
|
||||
if (editBtn)
|
||||
fireEvent.click(editBtn)
|
||||
})
|
||||
|
||||
const confirmEdit = screen.queryByTestId('confirm-edit')
|
||||
if (confirmEdit) {
|
||||
fireEvent.click(confirmEdit)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateAppInfo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appID: 'app-edit',
|
||||
name: 'Updated App Name',
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// -- Export flow --
|
||||
describe('Export App Flow', () => {
|
||||
it('should call exportAppConfig for completion apps', async () => {
|
||||
renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' })
|
||||
|
||||
const moreIcons = document.querySelectorAll('svg')
|
||||
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
|
||||
|
||||
if (moreFill) {
|
||||
const btn = moreFill.closest('[class*="cursor-pointer"]')
|
||||
if (btn)
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => {
|
||||
const exportBtn = screen.queryByText('app.export')
|
||||
if (exportBtn)
|
||||
fireEvent.click(exportBtn)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(exportAppConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ appID: 'app-export' }),
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// -- Access mode display --
|
||||
describe('Access Mode Display', () => {
|
||||
it('should not render operations menu for non-editor users', () => {
|
||||
mockIsCurrentWorkspaceEditor = false
|
||||
renderAppCard({ name: 'Readonly App' })
|
||||
|
||||
expect(screen.queryByText('app.editApp')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Switch mode (only for CHAT/COMPLETION) --
|
||||
describe('Switch App Mode', () => {
|
||||
it('should show switch option for chat mode apps', async () => {
|
||||
renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT })
|
||||
|
||||
const moreIcons = document.querySelectorAll('svg')
|
||||
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
|
||||
|
||||
if (moreFill) {
|
||||
const btn = moreFill.closest('[class*="cursor-pointer"]')
|
||||
if (btn)
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('app.switch')).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should not show switch option for workflow apps', async () => {
|
||||
renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' })
|
||||
|
||||
const moreIcons = document.querySelectorAll('svg')
|
||||
const moreFill = Array.from(moreIcons).find(svg => svg.closest('[class*="cursor-pointer"]'))
|
||||
|
||||
if (moreFill) {
|
||||
const btn = moreFill.closest('[class*="cursor-pointer"]')
|
||||
if (btn)
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
439
web/__tests__/apps/app-list-browsing-flow.test.tsx
Normal file
439
web/__tests__/apps/app-list-browsing-flow.test.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Integration test: App List Browsing Flow
|
||||
*
|
||||
* Tests the end-to-end user flow of browsing, filtering, searching,
|
||||
* and tab switching in the apps list page.
|
||||
*
|
||||
* Covers: List, Empty, Footer, AppCardSkeleton, useAppsQueryState, NewAppCard
|
||||
*/
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from '@/app/components/apps/list'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
let mockIsCurrentWorkspaceDatasetOperator = false
|
||||
let mockIsLoadingCurrentWorkspace = false
|
||||
|
||||
let mockSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
webapp_auth: { enabled: false },
|
||||
}
|
||||
|
||||
let mockPages: AppListResponse[] = []
|
||||
let mockIsLoading = false
|
||||
let mockIsFetching = false
|
||||
let mockIsFetchingNextPage = false
|
||||
let mockHasNextPage = false
|
||||
let mockError: Error | null = null
|
||||
const mockRefetch = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
|
||||
let mockShowTagManagementModal = false
|
||||
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockRouterReplace = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
replace: mockRouterReplace,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: (_loader: () => Promise<{ default: React.ComponentType }>) => {
|
||||
const LazyComponent = (props: Record<string, unknown>) => {
|
||||
return <div data-testid="dynamic-component" {...props} />
|
||||
}
|
||||
LazyComponent.displayName = 'DynamicComponent'
|
||||
return LazyComponent
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator,
|
||||
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = { systemFeatures: mockSystemFeatures }
|
||||
return selector ? selector(state) : state
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
tagList: [],
|
||||
showTagManagementModal: mockShowTagManagementModal,
|
||||
setTagList: vi.fn(),
|
||||
setShowTagManagementModal: vi.fn(),
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useInfiniteAppList: () => ({
|
||||
data: { pages: mockPages },
|
||||
isLoading: mockIsLoading,
|
||||
isFetching: mockIsFetching,
|
||||
isFetchingNextPage: mockIsFetchingNextPage,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: mockHasNextPage,
|
||||
error: mockError,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-pay', () => ({
|
||||
CheckModal: () => null,
|
||||
}))
|
||||
|
||||
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: (...args: unknown[]) => fnRef.current(...args),
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
id: overrides.id ?? 'app-1',
|
||||
name: overrides.name ?? 'My Chat App',
|
||||
description: overrides.description ?? 'A chat application',
|
||||
author_name: overrides.author_name ?? 'Test Author',
|
||||
icon_type: overrides.icon_type ?? 'emoji',
|
||||
icon: overrides.icon ?? '🤖',
|
||||
icon_background: overrides.icon_background ?? '#FFEAD5',
|
||||
icon_url: overrides.icon_url ?? null,
|
||||
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
|
||||
mode: overrides.mode ?? AppModeEnum.CHAT,
|
||||
enable_site: overrides.enable_site ?? true,
|
||||
enable_api: overrides.enable_api ?? true,
|
||||
api_rpm: overrides.api_rpm ?? 60,
|
||||
api_rph: overrides.api_rph ?? 3600,
|
||||
is_demo: overrides.is_demo ?? false,
|
||||
model_config: overrides.model_config ?? {} as App['model_config'],
|
||||
app_model_config: overrides.app_model_config ?? {} as App['app_model_config'],
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
updated_at: overrides.updated_at ?? 1700001000,
|
||||
site: overrides.site ?? {} as App['site'],
|
||||
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
|
||||
tags: overrides.tags ?? [],
|
||||
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
|
||||
max_active_requests: overrides.max_active_requests ?? null,
|
||||
})
|
||||
|
||||
const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse => ({
|
||||
data: apps,
|
||||
has_more: hasMore,
|
||||
limit: 30,
|
||||
page,
|
||||
total: apps.length,
|
||||
})
|
||||
|
||||
const renderList = (searchParams?: Record<string, string>) => {
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('App List Browsing Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceEditor = true
|
||||
mockIsCurrentWorkspaceDatasetOperator = false
|
||||
mockIsLoadingCurrentWorkspace = false
|
||||
mockSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
webapp_auth: { enabled: false },
|
||||
}
|
||||
mockPages = []
|
||||
mockIsLoading = false
|
||||
mockIsFetching = false
|
||||
mockIsFetchingNextPage = false
|
||||
mockHasNextPage = false
|
||||
mockError = null
|
||||
mockShowTagManagementModal = false
|
||||
})
|
||||
|
||||
// -- Loading and Empty states --
|
||||
describe('Loading and Empty States', () => {
|
||||
it('should show skeleton cards during initial loading', () => {
|
||||
mockIsLoading = true
|
||||
renderList()
|
||||
|
||||
const skeletonCards = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletonCards.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show empty state when no apps exist', () => {
|
||||
mockPages = [createPage([])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should transition from loading to content when data loads', () => {
|
||||
mockIsLoading = true
|
||||
const { rerender } = render(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
|
||||
const skeletonCards = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletonCards.length).toBeGreaterThan(0)
|
||||
|
||||
// Data loads
|
||||
mockIsLoading = false
|
||||
mockPages = [createPage([
|
||||
createMockApp({ id: 'app-1', name: 'Loaded App' }),
|
||||
])]
|
||||
|
||||
rerender(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Loaded App')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Rendering apps --
|
||||
describe('App List Rendering', () => {
|
||||
it('should render all app cards from the data', () => {
|
||||
mockPages = [createPage([
|
||||
createMockApp({ id: 'app-1', name: 'Chat Bot' }),
|
||||
createMockApp({ id: 'app-2', name: 'Workflow Engine', mode: AppModeEnum.WORKFLOW }),
|
||||
createMockApp({ id: 'app-3', name: 'Completion Tool', mode: AppModeEnum.COMPLETION }),
|
||||
])]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Chat Bot')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workflow Engine')).toBeInTheDocument()
|
||||
expect(screen.getByText('Completion Tool')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display app descriptions', () => {
|
||||
mockPages = [createPage([
|
||||
createMockApp({ name: 'My App', description: 'A powerful AI assistant' }),
|
||||
])]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('A powerful AI assistant')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the NewAppCard for workspace editors', () => {
|
||||
mockPages = [createPage([
|
||||
createMockApp({ name: 'Test App' }),
|
||||
])]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide NewAppCard when user is not a workspace editor', () => {
|
||||
mockIsCurrentWorkspaceEditor = false
|
||||
mockPages = [createPage([
|
||||
createMockApp({ name: 'Test App' }),
|
||||
])]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText('app.createApp')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Footer visibility --
|
||||
describe('Footer Visibility', () => {
|
||||
it('should show footer when branding is disabled', () => {
|
||||
mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: false } }
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.join')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.communityIntro')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide footer when branding is enabled', () => {
|
||||
mockSystemFeatures = { ...mockSystemFeatures, branding: { enabled: true } }
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText('app.join')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- DSL drag-drop hint --
|
||||
describe('DSL Drag-Drop Hint', () => {
|
||||
it('should show drag-drop hint for workspace editors', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide drag-drop hint for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor = false
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Tab navigation --
|
||||
describe('Tab Navigation', () => {
|
||||
it('should render all category tabs', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Search --
|
||||
describe('Search Filtering', () => {
|
||||
it('should render search input', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
const input = document.querySelector('input')
|
||||
expect(input).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should allow typing in search input', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
const input = document.querySelector('input')!
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
expect(input.value).toBe('test search')
|
||||
})
|
||||
})
|
||||
|
||||
// -- "Created by me" filter --
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render the "created by me" checkbox', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle the "created by me" filter on click', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
renderList()
|
||||
|
||||
const checkbox = screen.getByText('app.showMyCreatedAppsOnly')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Fetching next page skeleton --
|
||||
describe('Pagination Loading', () => {
|
||||
it('should show skeleton when fetching next page', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
mockIsFetchingNextPage = true
|
||||
|
||||
renderList()
|
||||
|
||||
const skeletonCards = document.querySelectorAll('.animate-pulse')
|
||||
expect(skeletonCards.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// -- Dataset operator redirect --
|
||||
describe('Dataset Operator Redirect', () => {
|
||||
it('should redirect dataset operators to /datasets', () => {
|
||||
mockIsCurrentWorkspaceDatasetOperator = true
|
||||
renderList()
|
||||
|
||||
expect(mockRouterReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
})
|
||||
|
||||
// -- Multiple pages of data --
|
||||
describe('Multi-page Data', () => {
|
||||
it('should render apps from multiple pages', () => {
|
||||
mockPages = [
|
||||
createPage([
|
||||
createMockApp({ id: 'app-1', name: 'Page One App' }),
|
||||
], true, 1),
|
||||
createPage([
|
||||
createMockApp({ id: 'app-2', name: 'Page Two App' }),
|
||||
], false, 2),
|
||||
]
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Page One App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Page Two App')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- controlRefreshList triggers refetch --
|
||||
describe('Refresh List', () => {
|
||||
it('should call refetch when controlRefreshList increments', () => {
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
|
||||
const { rerender } = render(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
|
||||
rerender(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={1} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
465
web/__tests__/apps/create-app-flow.test.tsx
Normal file
465
web/__tests__/apps/create-app-flow.test.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Integration test: Create App Flow
|
||||
*
|
||||
* Tests the end-to-end user flows for creating new apps:
|
||||
* - Creating from blank via NewAppCard
|
||||
* - Creating from template via NewAppCard
|
||||
* - Creating from DSL import via NewAppCard
|
||||
* - Apps page top-level state management
|
||||
*/
|
||||
import type { AppListResponse } from '@/models/app'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import List from '@/app/components/apps/list'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
let mockIsCurrentWorkspaceEditor = true
|
||||
let mockIsCurrentWorkspaceDatasetOperator = false
|
||||
let mockIsLoadingCurrentWorkspace = false
|
||||
let mockSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
webapp_auth: { enabled: false },
|
||||
}
|
||||
|
||||
let mockPages: AppListResponse[] = []
|
||||
let mockIsLoading = false
|
||||
let mockIsFetching = false
|
||||
const mockRefetch = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
let mockShowTagManagementModal = false
|
||||
|
||||
const mockRouterPush = vi.fn()
|
||||
const mockRouterReplace = vi.fn()
|
||||
const mockOnPlanInfoChanged = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockRouterPush,
|
||||
replace: mockRouterReplace,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor,
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator,
|
||||
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector?: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = { systemFeatures: mockSystemFeatures }
|
||||
return selector ? selector(state) : state
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
onPlanInfoChanged: mockOnPlanInfoChanged,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/tag-management/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
tagList: [],
|
||||
showTagManagementModal: mockShowTagManagementModal,
|
||||
setTagList: vi.fn(),
|
||||
setShowTagManagementModal: vi.fn(),
|
||||
}
|
||||
return selector(state)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList: vi.fn().mockResolvedValue([]),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-apps', () => ({
|
||||
useInfiniteAppList: () => ({
|
||||
data: { pages: mockPages },
|
||||
isLoading: mockIsLoading,
|
||||
isFetching: mockIsFetching,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: false,
|
||||
error: null,
|
||||
refetch: mockRefetch,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-pay', () => ({
|
||||
CheckModal: () => null,
|
||||
}))
|
||||
|
||||
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: (...args: unknown[]) => fnRef.current(...args),
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Mock dynamically loaded modals with test stubs
|
||||
vi.mock('next/dynamic', () => ({
|
||||
default: (loader: () => Promise<{ default: React.ComponentType }>) => {
|
||||
let Component: React.ComponentType<Record<string, unknown>> | null = null
|
||||
loader().then((mod) => {
|
||||
Component = mod.default as React.ComponentType<Record<string, unknown>>
|
||||
}).catch(() => {})
|
||||
const Wrapper = (props: Record<string, unknown>) => {
|
||||
if (Component)
|
||||
return <Component {...props} />
|
||||
return null
|
||||
}
|
||||
Wrapper.displayName = 'DynamicWrapper'
|
||||
return Wrapper
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-app-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess, onCreateFromTemplate }: Record<string, unknown>) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="create-app-modal">
|
||||
<button data-testid="create-blank-confirm" onClick={onSuccess as () => void}>Create Blank</button>
|
||||
{!!onCreateFromTemplate && (
|
||||
<button data-testid="switch-to-template" onClick={onCreateFromTemplate as () => void}>From Template</button>
|
||||
)}
|
||||
<button data-testid="create-blank-cancel" onClick={onClose as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-app-dialog', () => ({
|
||||
default: ({ show, onClose, onSuccess, onCreateFromBlank }: Record<string, unknown>) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="template-dialog">
|
||||
<button data-testid="template-confirm" onClick={onSuccess as () => void}>Create from Template</button>
|
||||
{!!onCreateFromBlank && (
|
||||
<button data-testid="switch-to-blank" onClick={onCreateFromBlank as () => void}>From Blank</button>
|
||||
)}
|
||||
<button data-testid="template-cancel" onClick={onClose as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
|
||||
default: ({ show, onClose, onSuccess }: Record<string, unknown>) => {
|
||||
if (!show)
|
||||
return null
|
||||
return (
|
||||
<div data-testid="create-from-dsl-modal">
|
||||
<button data-testid="dsl-import-confirm" onClick={onSuccess as () => void}>Import DSL</button>
|
||||
<button data-testid="dsl-import-cancel" onClick={onClose as () => void}>Cancel</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
CreateFromDSLModalTab: {
|
||||
FROM_URL: 'from-url',
|
||||
FROM_FILE: 'from-file',
|
||||
},
|
||||
}))
|
||||
|
||||
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||
id: overrides.id ?? 'app-1',
|
||||
name: overrides.name ?? 'Test App',
|
||||
description: overrides.description ?? 'A test app',
|
||||
author_name: overrides.author_name ?? 'Author',
|
||||
icon_type: overrides.icon_type ?? 'emoji',
|
||||
icon: overrides.icon ?? '🤖',
|
||||
icon_background: overrides.icon_background ?? '#FFEAD5',
|
||||
icon_url: overrides.icon_url ?? null,
|
||||
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
|
||||
mode: overrides.mode ?? AppModeEnum.CHAT,
|
||||
enable_site: overrides.enable_site ?? true,
|
||||
enable_api: overrides.enable_api ?? true,
|
||||
api_rpm: overrides.api_rpm ?? 60,
|
||||
api_rph: overrides.api_rph ?? 3600,
|
||||
is_demo: overrides.is_demo ?? false,
|
||||
model_config: overrides.model_config ?? {} as App['model_config'],
|
||||
app_model_config: overrides.app_model_config ?? {} as App['app_model_config'],
|
||||
created_at: overrides.created_at ?? 1700000000,
|
||||
updated_at: overrides.updated_at ?? 1700001000,
|
||||
site: overrides.site ?? {} as App['site'],
|
||||
api_base_url: overrides.api_base_url ?? 'https://api.example.com',
|
||||
tags: overrides.tags ?? [],
|
||||
access_mode: overrides.access_mode ?? AccessMode.PUBLIC,
|
||||
max_active_requests: overrides.max_active_requests ?? null,
|
||||
})
|
||||
|
||||
const createPage = (apps: App[]): AppListResponse => ({
|
||||
data: apps,
|
||||
has_more: false,
|
||||
limit: 30,
|
||||
page: 1,
|
||||
total: apps.length,
|
||||
})
|
||||
|
||||
const renderList = () => {
|
||||
return render(
|
||||
<NuqsTestingAdapter>
|
||||
<List controlRefreshList={0} />
|
||||
</NuqsTestingAdapter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Create App Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsCurrentWorkspaceEditor = true
|
||||
mockIsCurrentWorkspaceDatasetOperator = false
|
||||
mockIsLoadingCurrentWorkspace = false
|
||||
mockSystemFeatures = {
|
||||
branding: { enabled: false },
|
||||
webapp_auth: { enabled: false },
|
||||
}
|
||||
mockPages = [createPage([createMockApp()])]
|
||||
mockIsLoading = false
|
||||
mockIsFetching = false
|
||||
mockShowTagManagementModal = false
|
||||
})
|
||||
|
||||
// -- NewAppCard rendering --
|
||||
describe('NewAppCard Rendering', () => {
|
||||
it('should render the "Create App" card with all options', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.startFromBlank')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.startFromTemplate')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.importDSL')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render NewAppCard when user is not an editor', () => {
|
||||
mockIsCurrentWorkspaceEditor = false
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText('app.createApp')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading state when workspace is loading', () => {
|
||||
mockIsLoadingCurrentWorkspace = true
|
||||
renderList()
|
||||
|
||||
// NewAppCard renders but with loading style (pointer-events-none opacity-50)
|
||||
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -- Create from blank --
|
||||
describe('Create from Blank Flow', () => {
|
||||
it('should open the create app modal when "Start from Blank" is clicked', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close the create app modal on cancel', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('create-blank-cancel'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onPlanInfoChanged and refetch on successful creation', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('create-blank-confirm'))
|
||||
await waitFor(() => {
|
||||
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -- Create from template --
|
||||
describe('Create from Template Flow', () => {
|
||||
it('should open template dialog when "Start from Template" is clicked', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('template-dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow switching from template to blank modal', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('template-dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('switch-to-blank'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('template-dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should allow switching from blank to template dialog', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-app-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('switch-to-template'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('template-dialog')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -- Create from DSL import (via NewAppCard button) --
|
||||
describe('Create from DSL Import Flow', () => {
|
||||
it('should open DSL import modal when "Import DSL" is clicked', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.importDSL'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close DSL import modal on cancel', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.importDSL'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('dsl-import-cancel'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('create-from-dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onPlanInfoChanged and refetch on successful DSL import', async () => {
|
||||
renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.importDSL'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('create-from-dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('dsl-import-confirm'))
|
||||
await waitFor(() => {
|
||||
expect(mockOnPlanInfoChanged).toHaveBeenCalled()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// -- DSL drag-and-drop flow (via List component) --
|
||||
describe('DSL Drag-Drop Flow', () => {
|
||||
it('should show drag-drop hint in the list', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open create-from-DSL modal when DSL file is dropped', async () => {
|
||||
const { act } = await import('@testing-library/react')
|
||||
renderList()
|
||||
|
||||
const container = document.querySelector('[class*="overflow-y-auto"]')
|
||||
if (container) {
|
||||
const yamlFile = new File(['app: test'], 'app.yaml', { type: 'application/yaml' })
|
||||
|
||||
// Simulate the full drag-drop sequence wrapped in act
|
||||
await act(async () => {
|
||||
const dragEnterEvent = new Event('dragenter', { bubbles: true })
|
||||
Object.defineProperty(dragEnterEvent, 'dataTransfer', {
|
||||
value: { types: ['Files'], files: [] },
|
||||
})
|
||||
Object.defineProperty(dragEnterEvent, 'preventDefault', { value: vi.fn() })
|
||||
Object.defineProperty(dragEnterEvent, 'stopPropagation', { value: vi.fn() })
|
||||
container.dispatchEvent(dragEnterEvent)
|
||||
|
||||
const dropEvent = new Event('drop', { bubbles: true })
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: { files: [yamlFile], types: ['Files'] },
|
||||
})
|
||||
Object.defineProperty(dropEvent, 'preventDefault', { value: vi.fn() })
|
||||
Object.defineProperty(dropEvent, 'stopPropagation', { value: vi.fn() })
|
||||
container.dispatchEvent(dropEvent)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
const modal = screen.queryByTestId('create-from-dsl-modal')
|
||||
if (modal)
|
||||
expect(modal).toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// -- Edge cases --
|
||||
describe('Edge Cases', () => {
|
||||
it('should not show create options when no data and user is editor', () => {
|
||||
mockPages = [createPage([])]
|
||||
renderList()
|
||||
|
||||
// NewAppCard should still be visible even with no apps
|
||||
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle multiple rapid clicks on create buttons without crashing', async () => {
|
||||
renderList()
|
||||
|
||||
// Rapidly click different create options
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
||||
fireEvent.click(screen.getByText('app.importDSL'))
|
||||
|
||||
// Should not crash, and some modal should be present
|
||||
await waitFor(() => {
|
||||
const anyModal = screen.queryByTestId('create-app-modal')
|
||||
|| screen.queryByTestId('template-dialog')
|
||||
|| screen.queryByTestId('create-from-dsl-modal')
|
||||
expect(anyModal).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user