mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 17:38:04 +08: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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,16 +1,13 @@
|
|||||||
import type { Mock } from 'vitest'
|
import type { Mock } from 'vitest'
|
||||||
|
import type { App } from '@/types/app'
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { AccessMode } from '@/models/access-control'
|
import { AccessMode } from '@/models/access-control'
|
||||||
// Mock API services - import for direct manipulation
|
|
||||||
import * as appsService from '@/service/apps'
|
import * as appsService from '@/service/apps'
|
||||||
|
|
||||||
import * as exploreService from '@/service/explore'
|
import * as exploreService from '@/service/explore'
|
||||||
import * as workflowService from '@/service/workflow'
|
import * as workflowService from '@/service/workflow'
|
||||||
import { AppModeEnum } from '@/types/app'
|
import { AppModeEnum } from '@/types/app'
|
||||||
|
import AppCard from '../app-card'
|
||||||
// Import component after mocks
|
|
||||||
import AppCard from './app-card'
|
|
||||||
|
|
||||||
// Mock next/navigation
|
// Mock next/navigation
|
||||||
const mockPush = vi.fn()
|
const mockPush = vi.fn()
|
||||||
@ -24,11 +21,11 @@ vi.mock('next/navigation', () => ({
|
|||||||
// Include createContext for components that use it (like Toast)
|
// Include createContext for components that use it (like Toast)
|
||||||
const mockNotify = vi.fn()
|
const mockNotify = vi.fn()
|
||||||
vi.mock('use-context-selector', () => ({
|
vi.mock('use-context-selector', () => ({
|
||||||
createContext: (defaultValue: any) => React.createContext(defaultValue),
|
createContext: <T,>(defaultValue: T) => React.createContext(defaultValue),
|
||||||
useContext: () => ({
|
useContext: () => ({
|
||||||
notify: mockNotify,
|
notify: mockNotify,
|
||||||
}),
|
}),
|
||||||
useContextSelector: (_context: any, selector: any) => selector({
|
useContextSelector: (_context: unknown, selector: (state: Record<string, unknown>) => unknown) => selector({
|
||||||
notify: mockNotify,
|
notify: mockNotify,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
@ -51,7 +48,7 @@ vi.mock('@/context/provider-context', () => ({
|
|||||||
// Mock global public store - allow dynamic configuration
|
// Mock global public store - allow dynamic configuration
|
||||||
let mockWebappAuthEnabled = false
|
let mockWebappAuthEnabled = false
|
||||||
vi.mock('@/context/global-public-context', () => ({
|
vi.mock('@/context/global-public-context', () => ({
|
||||||
useGlobalPublicStore: (selector: (s: any) => any) => selector({
|
useGlobalPublicStore: (selector: (s: Record<string, unknown>) => unknown) => selector({
|
||||||
systemFeatures: {
|
systemFeatures: {
|
||||||
webapp_auth: { enabled: mockWebappAuthEnabled },
|
webapp_auth: { enabled: mockWebappAuthEnabled },
|
||||||
branding: { enabled: false },
|
branding: { enabled: false },
|
||||||
@ -106,11 +103,11 @@ vi.mock('@/utils/time', () => ({
|
|||||||
|
|
||||||
// Mock dynamic imports
|
// Mock dynamic imports
|
||||||
vi.mock('next/dynamic', () => ({
|
vi.mock('next/dynamic', () => ({
|
||||||
default: (importFn: () => Promise<any>) => {
|
default: (importFn: () => Promise<unknown>) => {
|
||||||
const fnString = importFn.toString()
|
const fnString = importFn.toString()
|
||||||
|
|
||||||
if (fnString.includes('create-app-modal') || fnString.includes('explore/create-app-modal')) {
|
if (fnString.includes('create-app-modal') || fnString.includes('explore/create-app-modal')) {
|
||||||
return function MockEditAppModal({ show, onHide, onConfirm }: any) {
|
return function MockEditAppModal({ show, onHide, onConfirm }: { show: boolean, onHide: () => void, onConfirm?: (data: Record<string, unknown>) => void }) {
|
||||||
if (!show)
|
if (!show)
|
||||||
return null
|
return null
|
||||||
return React.createElement('div', { 'data-testid': 'edit-app-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-edit-modal' }, 'Close'), React.createElement('button', {
|
return React.createElement('div', { 'data-testid': 'edit-app-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-edit-modal' }, 'Close'), React.createElement('button', {
|
||||||
@ -128,7 +125,7 @@ vi.mock('next/dynamic', () => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fnString.includes('duplicate-modal')) {
|
if (fnString.includes('duplicate-modal')) {
|
||||||
return function MockDuplicateAppModal({ show, onHide, onConfirm }: any) {
|
return function MockDuplicateAppModal({ show, onHide, onConfirm }: { show: boolean, onHide: () => void, onConfirm?: (data: Record<string, unknown>) => void }) {
|
||||||
if (!show)
|
if (!show)
|
||||||
return null
|
return null
|
||||||
return React.createElement('div', { 'data-testid': 'duplicate-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-duplicate-modal' }, 'Close'), React.createElement('button', {
|
return React.createElement('div', { 'data-testid': 'duplicate-modal' }, React.createElement('button', { 'onClick': onHide, 'data-testid': 'close-duplicate-modal' }, 'Close'), React.createElement('button', {
|
||||||
@ -143,26 +140,26 @@ vi.mock('next/dynamic', () => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fnString.includes('switch-app-modal')) {
|
if (fnString.includes('switch-app-modal')) {
|
||||||
return function MockSwitchAppModal({ show, onClose, onSuccess }: any) {
|
return function MockSwitchAppModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
|
||||||
if (!show)
|
if (!show)
|
||||||
return null
|
return null
|
||||||
return React.createElement('div', { 'data-testid': 'switch-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-switch-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'confirm-switch-modal' }, 'Switch'))
|
return React.createElement('div', { 'data-testid': 'switch-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-switch-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'confirm-switch-modal' }, 'Switch'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fnString.includes('base/confirm')) {
|
if (fnString.includes('base/confirm')) {
|
||||||
return function MockConfirm({ isShow, onCancel, onConfirm }: any) {
|
return function MockConfirm({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) {
|
||||||
if (!isShow)
|
if (!isShow)
|
||||||
return null
|
return null
|
||||||
return React.createElement('div', { 'data-testid': 'confirm-dialog' }, React.createElement('button', { 'onClick': onCancel, 'data-testid': 'cancel-confirm' }, 'Cancel'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-confirm' }, 'Confirm'))
|
return React.createElement('div', { 'data-testid': 'confirm-dialog' }, React.createElement('button', { 'onClick': onCancel, 'data-testid': 'cancel-confirm' }, 'Cancel'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-confirm' }, 'Confirm'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fnString.includes('dsl-export-confirm-modal')) {
|
if (fnString.includes('dsl-export-confirm-modal')) {
|
||||||
return function MockDSLExportModal({ onClose, onConfirm }: any) {
|
return function MockDSLExportModal({ onClose, onConfirm }: { onClose?: () => void, onConfirm?: (withSecrets: boolean) => void }) {
|
||||||
return React.createElement('div', { 'data-testid': 'dsl-export-modal' }, React.createElement('button', { 'onClick': () => onClose?.(), 'data-testid': 'close-dsl-export' }, 'Close'), React.createElement('button', { 'onClick': () => onConfirm?.(true), 'data-testid': 'confirm-dsl-export' }, 'Export with secrets'), React.createElement('button', { 'onClick': () => onConfirm?.(false), 'data-testid': 'confirm-dsl-export-no-secrets' }, 'Export without secrets'))
|
return React.createElement('div', { 'data-testid': 'dsl-export-modal' }, React.createElement('button', { 'onClick': () => onClose?.(), 'data-testid': 'close-dsl-export' }, 'Close'), React.createElement('button', { 'onClick': () => onConfirm?.(true), 'data-testid': 'confirm-dsl-export' }, 'Export with secrets'), React.createElement('button', { 'onClick': () => onConfirm?.(false), 'data-testid': 'confirm-dsl-export-no-secrets' }, 'Export without secrets'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fnString.includes('app-access-control')) {
|
if (fnString.includes('app-access-control')) {
|
||||||
return function MockAccessControl({ onClose, onConfirm }: any) {
|
return function MockAccessControl({ onClose, onConfirm }: { onClose: () => void, onConfirm: () => void }) {
|
||||||
return React.createElement('div', { 'data-testid': 'access-control-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-access-control' }, 'Close'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-access-control' }, 'Confirm'))
|
return React.createElement('div', { 'data-testid': 'access-control-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-access-control' }, 'Close'), React.createElement('button', { 'onClick': onConfirm, 'data-testid': 'confirm-access-control' }, 'Confirm'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -172,7 +169,9 @@ vi.mock('next/dynamic', () => ({
|
|||||||
|
|
||||||
// Popover uses @headlessui/react portals - mock for controlled interaction testing
|
// Popover uses @headlessui/react portals - mock for controlled interaction testing
|
||||||
vi.mock('@/app/components/base/popover', () => {
|
vi.mock('@/app/components/base/popover', () => {
|
||||||
const MockPopover = ({ htmlContent, btnElement, btnClassName }: any) => {
|
type PopoverHtmlContent = React.ReactNode | ((state: { open: boolean, onClose: () => void, onClick: () => void }) => React.ReactNode)
|
||||||
|
type MockPopoverProps = { htmlContent: PopoverHtmlContent, btnElement: React.ReactNode, btnClassName?: string | ((open: boolean) => string) }
|
||||||
|
const MockPopover = ({ htmlContent, btnElement, btnClassName }: MockPopoverProps) => {
|
||||||
const [isOpen, setIsOpen] = React.useState(false)
|
const [isOpen, setIsOpen] = React.useState(false)
|
||||||
const computedClassName = typeof btnClassName === 'function' ? btnClassName(isOpen) : ''
|
const computedClassName = typeof btnClassName === 'function' ? btnClassName(isOpen) : ''
|
||||||
return React.createElement('div', { 'data-testid': 'custom-popover', 'className': computedClassName }, React.createElement('div', {
|
return React.createElement('div', { 'data-testid': 'custom-popover', 'className': computedClassName }, React.createElement('div', {
|
||||||
@ -188,13 +187,13 @@ vi.mock('@/app/components/base/popover', () => {
|
|||||||
|
|
||||||
// Tooltip uses portals - minimal mock preserving popup content as title attribute
|
// Tooltip uses portals - minimal mock preserving popup content as title attribute
|
||||||
vi.mock('@/app/components/base/tooltip', () => ({
|
vi.mock('@/app/components/base/tooltip', () => ({
|
||||||
default: ({ children, popupContent }: any) => React.createElement('div', { title: popupContent }, children),
|
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: React.ReactNode }) => React.createElement('div', { title: popupContent }, children),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// TagSelector has API dependency (service/tag) - mock for isolated testing
|
// TagSelector has API dependency (service/tag) - mock for isolated testing
|
||||||
vi.mock('@/app/components/base/tag-management/selector', () => ({
|
vi.mock('@/app/components/base/tag-management/selector', () => ({
|
||||||
default: ({ tags }: any) => {
|
default: ({ tags }: { tags?: { id: string, name: string }[] }) => {
|
||||||
return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: any) => React.createElement('span', { key: tag.id }, tag.name)))
|
return React.createElement('div', { 'aria-label': 'tag-selector' }, tags?.map((tag: { id: string, name: string }) => React.createElement('span', { key: tag.id }, tag.name)))
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -203,11 +202,7 @@ vi.mock('@/app/components/app/type-selector', () => ({
|
|||||||
AppTypeIcon: () => React.createElement('div', { 'data-testid': 'app-type-icon' }),
|
AppTypeIcon: () => React.createElement('div', { 'data-testid': 'app-type-icon' }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// ============================================================================
|
const createMockApp = (overrides: Partial<App> = {}): App => ({
|
||||||
// Test Data Factories
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const createMockApp = (overrides: Record<string, any> = {}) => ({
|
|
||||||
id: 'test-app-id',
|
id: 'test-app-id',
|
||||||
name: 'Test App',
|
name: 'Test App',
|
||||||
description: 'Test app description',
|
description: 'Test app description',
|
||||||
@ -229,16 +224,8 @@ const createMockApp = (overrides: Record<string, any> = {}) => ({
|
|||||||
api_rpm: 60,
|
api_rpm: 60,
|
||||||
api_rph: 3600,
|
api_rph: 3600,
|
||||||
is_demo: false,
|
is_demo: false,
|
||||||
model_config: {} as any,
|
|
||||||
app_model_config: {} as any,
|
|
||||||
site: {} as any,
|
|
||||||
api_base_url: 'https://api.example.com',
|
|
||||||
...overrides,
|
...overrides,
|
||||||
})
|
} as App)
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Tests
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
describe('AppCard', () => {
|
describe('AppCard', () => {
|
||||||
const mockApp = createMockApp()
|
const mockApp = createMockApp()
|
||||||
@ -1171,7 +1158,7 @@ describe('AppCard', () => {
|
|||||||
(exploreService.fetchInstalledAppList as Mock).mockRejectedValueOnce(new Error('API Error'))
|
(exploreService.fetchInstalledAppList as Mock).mockRejectedValueOnce(new Error('API Error'))
|
||||||
|
|
||||||
// Configure mockOpenAsyncWindow to call the callback and trigger error
|
// Configure mockOpenAsyncWindow to call the callback and trigger error
|
||||||
mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options: any) => {
|
mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => {
|
||||||
try {
|
try {
|
||||||
await callback()
|
await callback()
|
||||||
}
|
}
|
||||||
@ -1213,7 +1200,7 @@ describe('AppCard', () => {
|
|||||||
(exploreService.fetchInstalledAppList as Mock).mockResolvedValueOnce({ installed_apps: [] })
|
(exploreService.fetchInstalledAppList as Mock).mockResolvedValueOnce({ installed_apps: [] })
|
||||||
|
|
||||||
// Configure mockOpenAsyncWindow to call the callback and trigger error
|
// Configure mockOpenAsyncWindow to call the callback and trigger error
|
||||||
mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options: any) => {
|
mockOpenAsyncWindow.mockImplementationOnce(async (callback: () => Promise<string>, options?: { onError?: (err: unknown) => void }) => {
|
||||||
try {
|
try {
|
||||||
await callback()
|
await callback()
|
||||||
}
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import Empty from './empty'
|
import Empty from '../empty'
|
||||||
|
|
||||||
describe('Empty', () => {
|
describe('Empty', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -21,7 +21,6 @@ describe('Empty', () => {
|
|||||||
|
|
||||||
it('should display the no apps found message', () => {
|
it('should display the no apps found message', () => {
|
||||||
render(<Empty />)
|
render(<Empty />)
|
||||||
// Use pattern matching for resilient text assertions
|
|
||||||
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
expect(screen.getByText('app.newApp.noAppsFound')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import Footer from './footer'
|
import Footer from '../footer'
|
||||||
|
|
||||||
describe('Footer', () => {
|
describe('Footer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -15,7 +15,6 @@ describe('Footer', () => {
|
|||||||
|
|
||||||
it('should display the community heading', () => {
|
it('should display the community heading', () => {
|
||||||
render(<Footer />)
|
render(<Footer />)
|
||||||
// Use pattern matching for resilient text assertions
|
|
||||||
expect(screen.getByText('app.join')).toBeInTheDocument()
|
expect(screen.getByText('app.join')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -3,21 +3,17 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
// Import after mocks
|
import Apps from '../index'
|
||||||
import Apps from './index'
|
|
||||||
|
|
||||||
// Track mock calls
|
|
||||||
let documentTitleCalls: string[] = []
|
let documentTitleCalls: string[] = []
|
||||||
let educationInitCalls: number = 0
|
let educationInitCalls: number = 0
|
||||||
|
|
||||||
// Mock useDocumentTitle hook
|
|
||||||
vi.mock('@/hooks/use-document-title', () => ({
|
vi.mock('@/hooks/use-document-title', () => ({
|
||||||
default: (title: string) => {
|
default: (title: string) => {
|
||||||
documentTitleCalls.push(title)
|
documentTitleCalls.push(title)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock useEducationInit hook
|
|
||||||
vi.mock('@/app/education-apply/hooks', () => ({
|
vi.mock('@/app/education-apply/hooks', () => ({
|
||||||
useEducationInit: () => {
|
useEducationInit: () => {
|
||||||
educationInitCalls++
|
educationInitCalls++
|
||||||
@ -33,8 +29,7 @@ vi.mock('@/hooks/use-import-dsl', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock List component
|
vi.mock('../list', () => ({
|
||||||
vi.mock('./list', () => ({
|
|
||||||
default: () => {
|
default: () => {
|
||||||
return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List')
|
return React.createElement('div', { 'data-testid': 'apps-list' }, 'Apps List')
|
||||||
},
|
},
|
||||||
@ -100,10 +95,7 @@ describe('Apps', () => {
|
|||||||
it('should render full component tree', () => {
|
it('should render full component tree', () => {
|
||||||
renderWithClient(<Apps />)
|
renderWithClient(<Apps />)
|
||||||
|
|
||||||
// Verify container exists
|
|
||||||
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
|
expect(screen.getByTestId('apps-list')).toBeInTheDocument()
|
||||||
|
|
||||||
// Verify hooks were called
|
|
||||||
expect(documentTitleCalls.length).toBeGreaterThanOrEqual(1)
|
expect(documentTitleCalls.length).toBeGreaterThanOrEqual(1)
|
||||||
expect(educationInitCalls).toBeGreaterThanOrEqual(1)
|
expect(educationInitCalls).toBeGreaterThanOrEqual(1)
|
||||||
})
|
})
|
||||||
@ -1,12 +1,13 @@
|
|||||||
|
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||||
import { AppModeEnum } from '@/types/app'
|
import { AppModeEnum } from '@/types/app'
|
||||||
|
|
||||||
// Import after mocks
|
import List from '../list'
|
||||||
import List from './list'
|
|
||||||
|
|
||||||
// Mock next/navigation
|
|
||||||
const mockReplace = vi.fn()
|
const mockReplace = vi.fn()
|
||||||
const mockRouter = { replace: mockReplace }
|
const mockRouter = { replace: mockReplace }
|
||||||
vi.mock('next/navigation', () => ({
|
vi.mock('next/navigation', () => ({
|
||||||
@ -14,7 +15,6 @@ vi.mock('next/navigation', () => ({
|
|||||||
useSearchParams: () => new URLSearchParams(''),
|
useSearchParams: () => new URLSearchParams(''),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock app context
|
|
||||||
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
|
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
|
||||||
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
|
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
|
||||||
vi.mock('@/context/app-context', () => ({
|
vi.mock('@/context/app-context', () => ({
|
||||||
@ -24,7 +24,6 @@ vi.mock('@/context/app-context', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock global public store
|
|
||||||
vi.mock('@/context/global-public-context', () => ({
|
vi.mock('@/context/global-public-context', () => ({
|
||||||
useGlobalPublicStore: () => ({
|
useGlobalPublicStore: () => ({
|
||||||
systemFeatures: {
|
systemFeatures: {
|
||||||
@ -33,41 +32,28 @@ vi.mock('@/context/global-public-context', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock custom hooks - allow dynamic query state
|
|
||||||
const mockSetQuery = vi.fn()
|
const mockSetQuery = vi.fn()
|
||||||
const mockQueryState = {
|
const mockQueryState = {
|
||||||
tagIDs: [] as string[],
|
tagIDs: [] as string[],
|
||||||
keywords: '',
|
keywords: '',
|
||||||
isCreatedByMe: false,
|
isCreatedByMe: false,
|
||||||
}
|
}
|
||||||
vi.mock('./hooks/use-apps-query-state', () => ({
|
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||||
default: () => ({
|
default: () => ({
|
||||||
query: mockQueryState,
|
query: mockQueryState,
|
||||||
setQuery: mockSetQuery,
|
setQuery: mockSetQuery,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Store callback for testing DSL file drop
|
|
||||||
let mockOnDSLFileDropped: ((file: File) => void) | null = null
|
let mockOnDSLFileDropped: ((file: File) => void) | null = null
|
||||||
let mockDragging = false
|
let mockDragging = false
|
||||||
vi.mock('./hooks/use-dsl-drag-drop', () => ({
|
vi.mock('../hooks/use-dsl-drag-drop', () => ({
|
||||||
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
|
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
|
||||||
mockOnDSLFileDropped = onDSLFileDropped
|
mockOnDSLFileDropped = onDSLFileDropped
|
||||||
return { dragging: mockDragging }
|
return { dragging: mockDragging }
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mockSetActiveTab = vi.fn()
|
|
||||||
vi.mock('nuqs', () => ({
|
|
||||||
useQueryState: () => ['all', mockSetActiveTab],
|
|
||||||
parseAsString: {
|
|
||||||
withDefault: () => ({
|
|
||||||
withOptions: () => ({}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock service hooks - use object for mutable state (vi.mock is hoisted)
|
|
||||||
const mockRefetch = vi.fn()
|
const mockRefetch = vi.fn()
|
||||||
const mockFetchNextPage = vi.fn()
|
const mockFetchNextPage = vi.fn()
|
||||||
|
|
||||||
@ -124,47 +110,20 @@ vi.mock('@/service/use-apps', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Use real tag store - global zustand mock will auto-reset between tests
|
|
||||||
|
|
||||||
// Mock tag service to avoid API calls in TagFilter
|
|
||||||
vi.mock('@/service/tag', () => ({
|
vi.mock('@/service/tag', () => ({
|
||||||
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
|
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Store TagFilter onChange callback for testing
|
|
||||||
let mockTagFilterOnChange: ((value: string[]) => void) | null = null
|
|
||||||
vi.mock('@/app/components/base/tag-management/filter', () => ({
|
|
||||||
default: ({ onChange }: { onChange: (value: string[]) => void }) => {
|
|
||||||
mockTagFilterOnChange = onChange
|
|
||||||
return React.createElement('div', { 'data-testid': 'tag-filter' }, 'common.tag.placeholder')
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock config
|
|
||||||
vi.mock('@/config', () => ({
|
vi.mock('@/config', () => ({
|
||||||
NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList',
|
NEED_REFRESH_APP_LIST_KEY: 'needRefreshAppList',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock pay hook
|
|
||||||
vi.mock('@/hooks/use-pay', () => ({
|
vi.mock('@/hooks/use-pay', () => ({
|
||||||
CheckModal: () => null,
|
CheckModal: () => null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock ahooks - useMount only executes once on mount, not on fn change
|
|
||||||
vi.mock('ahooks', () => ({
|
|
||||||
useDebounceFn: (fn: () => void) => ({ run: fn }),
|
|
||||||
useMount: (fn: () => void) => {
|
|
||||||
const fnRef = React.useRef(fn)
|
|
||||||
fnRef.current = fn
|
|
||||||
React.useEffect(() => {
|
|
||||||
fnRef.current()
|
|
||||||
}, [])
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Mock dynamic imports
|
|
||||||
vi.mock('next/dynamic', () => ({
|
vi.mock('next/dynamic', () => ({
|
||||||
default: (importFn: () => Promise<any>) => {
|
default: (importFn: () => Promise<unknown>) => {
|
||||||
const fnString = importFn.toString()
|
const fnString = importFn.toString()
|
||||||
|
|
||||||
if (fnString.includes('tag-management')) {
|
if (fnString.includes('tag-management')) {
|
||||||
@ -173,7 +132,7 @@ vi.mock('next/dynamic', () => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fnString.includes('create-from-dsl-modal')) {
|
if (fnString.includes('create-from-dsl-modal')) {
|
||||||
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: any) {
|
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
|
||||||
if (!show)
|
if (!show)
|
||||||
return null
|
return null
|
||||||
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
|
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
|
||||||
@ -183,41 +142,34 @@ vi.mock('next/dynamic', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
/**
|
vi.mock('../app-card', () => ({
|
||||||
* Mock child components for focused List component testing.
|
default: ({ app }: { app: { id: string, name: string } }) => {
|
||||||
* These mocks isolate the List component's behavior from its children.
|
|
||||||
* Each child component (AppCard, NewAppCard, Empty, Footer) has its own dedicated tests.
|
|
||||||
*/
|
|
||||||
vi.mock('./app-card', () => ({
|
|
||||||
default: ({ app }: any) => {
|
|
||||||
return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name)
|
return React.createElement('div', { 'data-testid': `app-card-${app.id}`, 'role': 'article' }, app.name)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('./new-app-card', () => ({
|
vi.mock('../new-app-card', () => ({
|
||||||
default: React.forwardRef((_props: any, _ref: any) => {
|
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => {
|
||||||
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
|
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('./empty', () => ({
|
vi.mock('../empty', () => ({
|
||||||
default: () => {
|
default: () => {
|
||||||
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
|
return React.createElement('div', { 'data-testid': 'empty-state', 'role': 'status' }, 'No apps found')
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('./footer', () => ({
|
vi.mock('../footer', () => ({
|
||||||
default: () => {
|
default: () => {
|
||||||
return React.createElement('footer', { 'data-testid': 'footer', 'role': 'contentinfo' }, 'Footer')
|
return React.createElement('footer', { 'data-testid': 'footer', 'role': 'contentinfo' }, 'Footer')
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Store IntersectionObserver callback
|
|
||||||
let intersectionCallback: IntersectionObserverCallback | null = null
|
let intersectionCallback: IntersectionObserverCallback | null = null
|
||||||
const mockObserve = vi.fn()
|
const mockObserve = vi.fn()
|
||||||
const mockDisconnect = vi.fn()
|
const mockDisconnect = vi.fn()
|
||||||
|
|
||||||
// Mock IntersectionObserver
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
globalThis.IntersectionObserver = class MockIntersectionObserver {
|
globalThis.IntersectionObserver = class MockIntersectionObserver {
|
||||||
constructor(callback: IntersectionObserverCallback) {
|
constructor(callback: IntersectionObserverCallback) {
|
||||||
@ -234,10 +186,21 @@ beforeAll(() => {
|
|||||||
} as unknown as typeof IntersectionObserver
|
} as unknown as typeof IntersectionObserver
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Render helper wrapping with NuqsTestingAdapter
|
||||||
|
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||||
|
const renderList = (searchParams = '') => {
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
|
||||||
|
{children}
|
||||||
|
</NuqsTestingAdapter>
|
||||||
|
)
|
||||||
|
return render(<List />, { wrapper })
|
||||||
|
}
|
||||||
|
|
||||||
describe('List', () => {
|
describe('List', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
// Set up tag store state
|
onUrlUpdate.mockClear()
|
||||||
useTagStore.setState({
|
useTagStore.setState({
|
||||||
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
|
tagList: [{ id: 'tag-1', name: 'Test Tag', type: 'app', binding_count: 0 }],
|
||||||
showTagManagementModal: false,
|
showTagManagementModal: false,
|
||||||
@ -246,7 +209,6 @@ describe('List', () => {
|
|||||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||||
mockDragging = false
|
mockDragging = false
|
||||||
mockOnDSLFileDropped = null
|
mockOnDSLFileDropped = null
|
||||||
mockTagFilterOnChange = null
|
|
||||||
mockServiceState.error = null
|
mockServiceState.error = null
|
||||||
mockServiceState.hasNextPage = false
|
mockServiceState.hasNextPage = false
|
||||||
mockServiceState.isLoading = false
|
mockServiceState.isLoading = false
|
||||||
@ -260,13 +222,12 @@ describe('List', () => {
|
|||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('should render without crashing', () => {
|
it('should render without crashing', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
// Tab slider renders app type tabs
|
|
||||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render tab slider with all app types', () => {
|
it('should render tab slider with all app types', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||||
@ -277,71 +238,74 @@ describe('List', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should render search input', () => {
|
it('should render search input', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
// Input component renders a searchbox
|
|
||||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render tag filter', () => {
|
it('should render tag filter', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
// Tag filter renders with placeholder text
|
|
||||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render created by me checkbox', () => {
|
it('should render created by me checkbox', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render app cards when apps exist', () => {
|
it('should render app cards when apps exist', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render new app card for editors', () => {
|
it('should render new app card for editors', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
|
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render footer when branding is disabled', () => {
|
it('should render footer when branding is disabled', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render drop DSL hint for editors', () => {
|
it('should render drop DSL hint for editors', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Tab Navigation', () => {
|
describe('Tab Navigation', () => {
|
||||||
it('should call setActiveTab when tab is clicked', () => {
|
it('should update URL when workflow tab is clicked', async () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||||
|
|
||||||
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
|
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||||
|
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||||
|
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call setActiveTab for all tab', () => {
|
it('should update URL when all tab is clicked', async () => {
|
||||||
render(<List />)
|
renderList('?category=workflow')
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('app.types.all'))
|
fireEvent.click(screen.getByText('app.types.all'))
|
||||||
|
|
||||||
expect(mockSetActiveTab).toHaveBeenCalledWith('all')
|
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||||
|
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||||
|
// nuqs removes the default value ('all') from URL params
|
||||||
|
expect(lastCall.searchParams.has('category')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Search Functionality', () => {
|
describe('Search Functionality', () => {
|
||||||
it('should render search input field', () => {
|
it('should render search input field', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle search input change', () => {
|
it('should handle search input change', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
const input = screen.getByRole('textbox')
|
||||||
fireEvent.change(input, { target: { value: 'test search' } })
|
fireEvent.change(input, { target: { value: 'test search' } })
|
||||||
@ -349,55 +313,36 @@ describe('List', () => {
|
|||||||
expect(mockSetQuery).toHaveBeenCalled()
|
expect(mockSetQuery).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle search input interaction', () => {
|
|
||||||
render(<List />)
|
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
|
||||||
expect(input).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle search clear button click', () => {
|
it('should handle search clear button click', () => {
|
||||||
// Set initial keywords to make clear button visible
|
|
||||||
mockQueryState.keywords = 'existing search'
|
mockQueryState.keywords = 'existing search'
|
||||||
|
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
// Find and click clear button (Input component uses .group class for clear icon container)
|
|
||||||
const clearButton = document.querySelector('.group')
|
const clearButton = document.querySelector('.group')
|
||||||
expect(clearButton).toBeInTheDocument()
|
expect(clearButton).toBeInTheDocument()
|
||||||
if (clearButton)
|
if (clearButton)
|
||||||
fireEvent.click(clearButton)
|
fireEvent.click(clearButton)
|
||||||
|
|
||||||
// handleKeywordsChange should be called with empty string
|
|
||||||
expect(mockSetQuery).toHaveBeenCalled()
|
expect(mockSetQuery).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Tag Filter', () => {
|
describe('Tag Filter', () => {
|
||||||
it('should render tag filter component', () => {
|
it('should render tag filter component', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render tag filter with placeholder', () => {
|
|
||||||
render(<List />)
|
|
||||||
|
|
||||||
// Tag filter is rendered
|
|
||||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Created By Me Filter', () => {
|
describe('Created By Me Filter', () => {
|
||||||
it('should render checkbox with correct label', () => {
|
it('should render checkbox with correct label', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle checkbox change', () => {
|
it('should handle checkbox change', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
// Checkbox component uses data-testid="checkbox-{id}"
|
|
||||||
// CheckboxWithLabel doesn't pass testId, so id is undefined
|
|
||||||
const checkbox = screen.getByTestId('checkbox-undefined')
|
const checkbox = screen.getByTestId('checkbox-undefined')
|
||||||
fireEvent.click(checkbox)
|
fireEvent.click(checkbox)
|
||||||
|
|
||||||
@ -409,7 +354,7 @@ describe('List', () => {
|
|||||||
it('should not render new app card for non-editors', () => {
|
it('should not render new app card for non-editors', () => {
|
||||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||||
|
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@ -417,7 +362,7 @@ describe('List', () => {
|
|||||||
it('should not render drop DSL hint for non-editors', () => {
|
it('should not render drop DSL hint for non-editors', () => {
|
||||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||||
|
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
|
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@ -427,7 +372,7 @@ describe('List', () => {
|
|||||||
it('should redirect dataset operators to datasets page', () => {
|
it('should redirect dataset operators to datasets page', () => {
|
||||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
|
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
|
||||||
|
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||||
})
|
})
|
||||||
@ -437,7 +382,7 @@ describe('List', () => {
|
|||||||
it('should call refetch when refresh key is set in localStorage', () => {
|
it('should call refetch when refresh key is set in localStorage', () => {
|
||||||
localStorage.setItem('needRefreshAppList', '1')
|
localStorage.setItem('needRefreshAppList', '1')
|
||||||
|
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
expect(mockRefetch).toHaveBeenCalled()
|
expect(mockRefetch).toHaveBeenCalled()
|
||||||
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
|
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
|
||||||
@ -446,22 +391,30 @@ describe('List', () => {
|
|||||||
|
|
||||||
describe('Edge Cases', () => {
|
describe('Edge Cases', () => {
|
||||||
it('should handle multiple renders without issues', () => {
|
it('should handle multiple renders without issues', () => {
|
||||||
const { rerender } = render(<List />)
|
const { rerender } = render(
|
||||||
|
<NuqsTestingAdapter>
|
||||||
|
<List />
|
||||||
|
</NuqsTestingAdapter>,
|
||||||
|
)
|
||||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||||
|
|
||||||
rerender(<List />)
|
rerender(
|
||||||
|
<NuqsTestingAdapter>
|
||||||
|
<List />
|
||||||
|
</NuqsTestingAdapter>,
|
||||||
|
)
|
||||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render app cards correctly', () => {
|
it('should render app cards correctly', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render with all filter options visible', () => {
|
it('should render with all filter options visible', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||||
@ -471,14 +424,20 @@ describe('List', () => {
|
|||||||
|
|
||||||
describe('Dragging State', () => {
|
describe('Dragging State', () => {
|
||||||
it('should show drop hint when DSL feature is enabled for editors', () => {
|
it('should show drop hint when DSL feature is enabled for editors', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render dragging state overlay when dragging', () => {
|
||||||
|
mockDragging = true
|
||||||
|
const { container } = renderList()
|
||||||
|
expect(container).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('App Type Tabs', () => {
|
describe('App Type Tabs', () => {
|
||||||
it('should render all app type tabs', () => {
|
it('should render all app type tabs', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||||
@ -488,8 +447,8 @@ describe('List', () => {
|
|||||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call setActiveTab for each app type', () => {
|
it('should update URL for each app type tab click', async () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
const appTypeTexts = [
|
const appTypeTexts = [
|
||||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||||
@ -499,45 +458,26 @@ describe('List', () => {
|
|||||||
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
|
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
|
||||||
]
|
]
|
||||||
|
|
||||||
appTypeTexts.forEach(({ mode, text }) => {
|
for (const { mode, text } of appTypeTexts) {
|
||||||
|
onUrlUpdate.mockClear()
|
||||||
fireEvent.click(screen.getByText(text))
|
fireEvent.click(screen.getByText(text))
|
||||||
expect(mockSetActiveTab).toHaveBeenCalledWith(mode)
|
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||||
})
|
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||||
})
|
expect(lastCall.searchParams.get('category')).toBe(mode)
|
||||||
})
|
}
|
||||||
|
|
||||||
describe('Search and Filter Integration', () => {
|
|
||||||
it('should display search input with correct attributes', () => {
|
|
||||||
render(<List />)
|
|
||||||
|
|
||||||
const input = screen.getByRole('textbox')
|
|
||||||
expect(input).toBeInTheDocument()
|
|
||||||
expect(input).toHaveAttribute('value', '')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should have tag filter component', () => {
|
|
||||||
render(<List />)
|
|
||||||
|
|
||||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should display created by me label', () => {
|
|
||||||
render(<List />)
|
|
||||||
|
|
||||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('App List Display', () => {
|
describe('App List Display', () => {
|
||||||
it('should display all app cards from data', () => {
|
it('should display all app cards from data', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display app names correctly', () => {
|
it('should display app names correctly', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||||
@ -546,59 +486,27 @@ describe('List', () => {
|
|||||||
|
|
||||||
describe('Footer Visibility', () => {
|
describe('Footer Visibility', () => {
|
||||||
it('should render footer when branding is disabled', () => {
|
it('should render footer when branding is disabled', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// Additional Coverage Tests
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
describe('Additional Coverage', () => {
|
|
||||||
it('should render dragging state overlay when dragging', () => {
|
|
||||||
mockDragging = true
|
|
||||||
const { container } = render(<List />)
|
|
||||||
|
|
||||||
// Component should render successfully with dragging state
|
|
||||||
expect(container).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle app mode filter in query params', () => {
|
|
||||||
render(<List />)
|
|
||||||
|
|
||||||
const workflowTab = screen.getByText('app.types.workflow')
|
|
||||||
fireEvent.click(workflowTab)
|
|
||||||
|
|
||||||
expect(mockSetActiveTab).toHaveBeenCalledWith(AppModeEnum.WORKFLOW)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render new app card for editors', () => {
|
|
||||||
render(<List />)
|
|
||||||
|
|
||||||
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('DSL File Drop', () => {
|
describe('DSL File Drop', () => {
|
||||||
it('should handle DSL file drop and show modal', () => {
|
it('should handle DSL file drop and show modal', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
// Simulate DSL file drop via the callback
|
|
||||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||||
act(() => {
|
act(() => {
|
||||||
if (mockOnDSLFileDropped)
|
if (mockOnDSLFileDropped)
|
||||||
mockOnDSLFileDropped(mockFile)
|
mockOnDSLFileDropped(mockFile)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Modal should be shown
|
|
||||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should close DSL modal when onClose is called', () => {
|
it('should close DSL modal when onClose is called', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
// Open modal via DSL file drop
|
|
||||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||||
act(() => {
|
act(() => {
|
||||||
if (mockOnDSLFileDropped)
|
if (mockOnDSLFileDropped)
|
||||||
@ -607,16 +515,14 @@ describe('List', () => {
|
|||||||
|
|
||||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||||
|
|
||||||
// Close modal
|
|
||||||
fireEvent.click(screen.getByTestId('close-dsl-modal'))
|
fireEvent.click(screen.getByTestId('close-dsl-modal'))
|
||||||
|
|
||||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should close DSL modal and refetch when onSuccess is called', () => {
|
it('should close DSL modal and refetch when onSuccess is called', () => {
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
// Open modal via DSL file drop
|
|
||||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||||
act(() => {
|
act(() => {
|
||||||
if (mockOnDSLFileDropped)
|
if (mockOnDSLFileDropped)
|
||||||
@ -625,67 +531,18 @@ describe('List', () => {
|
|||||||
|
|
||||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||||
|
|
||||||
// Click success button
|
|
||||||
fireEvent.click(screen.getByTestId('success-dsl-modal'))
|
fireEvent.click(screen.getByTestId('success-dsl-modal'))
|
||||||
|
|
||||||
// Modal should be closed and refetch should be called
|
|
||||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||||
expect(mockRefetch).toHaveBeenCalled()
|
expect(mockRefetch).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Tag Filter Change', () => {
|
|
||||||
it('should handle tag filter value change', () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
render(<List />)
|
|
||||||
|
|
||||||
// TagFilter component is rendered
|
|
||||||
expect(screen.getByTestId('tag-filter')).toBeInTheDocument()
|
|
||||||
|
|
||||||
// Trigger tag filter change via captured callback
|
|
||||||
act(() => {
|
|
||||||
if (mockTagFilterOnChange)
|
|
||||||
mockTagFilterOnChange(['tag-1', 'tag-2'])
|
|
||||||
})
|
|
||||||
|
|
||||||
// Advance timers to trigger debounced setTagIDs
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(500)
|
|
||||||
})
|
|
||||||
|
|
||||||
// setQuery should have been called with updated tagIDs
|
|
||||||
expect(mockSetQuery).toHaveBeenCalled()
|
|
||||||
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle empty tag filter selection', () => {
|
|
||||||
vi.useFakeTimers()
|
|
||||||
render(<List />)
|
|
||||||
|
|
||||||
// Trigger tag filter change with empty array
|
|
||||||
act(() => {
|
|
||||||
if (mockTagFilterOnChange)
|
|
||||||
mockTagFilterOnChange([])
|
|
||||||
})
|
|
||||||
|
|
||||||
// Advance timers
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(500)
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(mockSetQuery).toHaveBeenCalled()
|
|
||||||
|
|
||||||
vi.useRealTimers()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Infinite Scroll', () => {
|
describe('Infinite Scroll', () => {
|
||||||
it('should call fetchNextPage when intersection observer triggers', () => {
|
it('should call fetchNextPage when intersection observer triggers', () => {
|
||||||
mockServiceState.hasNextPage = true
|
mockServiceState.hasNextPage = true
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
// Simulate intersection
|
|
||||||
if (intersectionCallback) {
|
if (intersectionCallback) {
|
||||||
act(() => {
|
act(() => {
|
||||||
intersectionCallback!(
|
intersectionCallback!(
|
||||||
@ -700,9 +557,8 @@ describe('List', () => {
|
|||||||
|
|
||||||
it('should not call fetchNextPage when not intersecting', () => {
|
it('should not call fetchNextPage when not intersecting', () => {
|
||||||
mockServiceState.hasNextPage = true
|
mockServiceState.hasNextPage = true
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
// Simulate non-intersection
|
|
||||||
if (intersectionCallback) {
|
if (intersectionCallback) {
|
||||||
act(() => {
|
act(() => {
|
||||||
intersectionCallback!(
|
intersectionCallback!(
|
||||||
@ -718,7 +574,7 @@ describe('List', () => {
|
|||||||
it('should not call fetchNextPage when loading', () => {
|
it('should not call fetchNextPage when loading', () => {
|
||||||
mockServiceState.hasNextPage = true
|
mockServiceState.hasNextPage = true
|
||||||
mockServiceState.isLoading = true
|
mockServiceState.isLoading = true
|
||||||
render(<List />)
|
renderList()
|
||||||
|
|
||||||
if (intersectionCallback) {
|
if (intersectionCallback) {
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -736,11 +592,8 @@ describe('List', () => {
|
|||||||
describe('Error State', () => {
|
describe('Error State', () => {
|
||||||
it('should handle error state in useEffect', () => {
|
it('should handle error state in useEffect', () => {
|
||||||
mockServiceState.error = new Error('Test error')
|
mockServiceState.error = new Error('Test error')
|
||||||
const { container } = render(<List />)
|
const { container } = renderList()
|
||||||
|
|
||||||
// Component should still render
|
|
||||||
expect(container).toBeInTheDocument()
|
expect(container).toBeInTheDocument()
|
||||||
// Disconnect should be called when there's an error (cleanup)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -1,10 +1,8 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
// Import after mocks
|
import CreateAppCard from '../new-app-card'
|
||||||
import CreateAppCard from './new-app-card'
|
|
||||||
|
|
||||||
// Mock next/navigation
|
|
||||||
const mockReplace = vi.fn()
|
const mockReplace = vi.fn()
|
||||||
vi.mock('next/navigation', () => ({
|
vi.mock('next/navigation', () => ({
|
||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
@ -13,7 +11,6 @@ vi.mock('next/navigation', () => ({
|
|||||||
useSearchParams: () => new URLSearchParams(),
|
useSearchParams: () => new URLSearchParams(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock provider context
|
|
||||||
const mockOnPlanInfoChanged = vi.fn()
|
const mockOnPlanInfoChanged = vi.fn()
|
||||||
vi.mock('@/context/provider-context', () => ({
|
vi.mock('@/context/provider-context', () => ({
|
||||||
useProviderContext: () => ({
|
useProviderContext: () => ({
|
||||||
@ -21,37 +18,35 @@ vi.mock('@/context/provider-context', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock next/dynamic to immediately resolve components
|
|
||||||
vi.mock('next/dynamic', () => ({
|
vi.mock('next/dynamic', () => ({
|
||||||
default: (importFn: () => Promise<any>) => {
|
default: (importFn: () => Promise<{ default: React.ComponentType }>) => {
|
||||||
const fnString = importFn.toString()
|
const fnString = importFn.toString()
|
||||||
|
|
||||||
if (fnString.includes('create-app-modal') && !fnString.includes('create-from-dsl-modal')) {
|
if (fnString.includes('create-app-modal') && !fnString.includes('create-from-dsl-modal')) {
|
||||||
return function MockCreateAppModal({ show, onClose, onSuccess, onCreateFromTemplate }: any) {
|
return function MockCreateAppModal({ show, onClose, onSuccess, onCreateFromTemplate }: Record<string, unknown>) {
|
||||||
if (!show)
|
if (!show)
|
||||||
return null
|
return null
|
||||||
return React.createElement('div', { 'data-testid': 'create-app-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-create-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-create-modal' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromTemplate, 'data-testid': 'to-template-modal' }, 'To Template'))
|
return React.createElement('div', { 'data-testid': 'create-app-modal' }, React.createElement('button', { 'onClick': onClose as () => void, 'data-testid': 'close-create-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-create-modal' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromTemplate as () => void, 'data-testid': 'to-template-modal' }, 'To Template'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fnString.includes('create-app-dialog')) {
|
if (fnString.includes('create-app-dialog')) {
|
||||||
return function MockCreateAppTemplateDialog({ show, onClose, onSuccess, onCreateFromBlank }: any) {
|
return function MockCreateAppTemplateDialog({ show, onClose, onSuccess, onCreateFromBlank }: Record<string, unknown>) {
|
||||||
if (!show)
|
if (!show)
|
||||||
return null
|
return null
|
||||||
return React.createElement('div', { 'data-testid': 'create-template-dialog' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-template-dialog' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-template-dialog' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromBlank, 'data-testid': 'to-blank-modal' }, 'To Blank'))
|
return React.createElement('div', { 'data-testid': 'create-template-dialog' }, React.createElement('button', { 'onClick': onClose as () => void, 'data-testid': 'close-template-dialog' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-template-dialog' }, 'Success'), React.createElement('button', { 'onClick': onCreateFromBlank as () => void, 'data-testid': 'to-blank-modal' }, 'To Blank'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (fnString.includes('create-from-dsl-modal')) {
|
if (fnString.includes('create-from-dsl-modal')) {
|
||||||
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: any) {
|
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: Record<string, unknown>) {
|
||||||
if (!show)
|
if (!show)
|
||||||
return null
|
return null
|
||||||
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
|
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose as () => void, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess as () => void, 'data-testid': 'success-dsl-modal' }, 'Success'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return () => null
|
return () => null
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock CreateFromDSLModalTab enum
|
|
||||||
vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
|
vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
|
||||||
CreateFromDSLModalTab: {
|
CreateFromDSLModalTab: {
|
||||||
FROM_URL: 'from-url',
|
FROM_URL: 'from-url',
|
||||||
@ -68,7 +63,6 @@ describe('CreateAppCard', () => {
|
|||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('should render without crashing', () => {
|
it('should render without crashing', () => {
|
||||||
render(<CreateAppCard ref={defaultRef} />)
|
render(<CreateAppCard ref={defaultRef} />)
|
||||||
// Use pattern matching for resilient text assertions
|
|
||||||
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
expect(screen.getByText('app.createApp')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -245,19 +239,15 @@ describe('CreateAppCard', () => {
|
|||||||
it('should handle multiple modal opens/closes', () => {
|
it('should handle multiple modal opens/closes', () => {
|
||||||
render(<CreateAppCard ref={defaultRef} />)
|
render(<CreateAppCard ref={defaultRef} />)
|
||||||
|
|
||||||
// Open and close create modal
|
|
||||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||||
fireEvent.click(screen.getByTestId('close-create-modal'))
|
fireEvent.click(screen.getByTestId('close-create-modal'))
|
||||||
|
|
||||||
// Open and close template dialog
|
|
||||||
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
fireEvent.click(screen.getByText('app.newApp.startFromTemplate'))
|
||||||
fireEvent.click(screen.getByTestId('close-template-dialog'))
|
fireEvent.click(screen.getByTestId('close-template-dialog'))
|
||||||
|
|
||||||
// Open and close DSL modal
|
|
||||||
fireEvent.click(screen.getByText('app.importDSL'))
|
fireEvent.click(screen.getByText('app.importDSL'))
|
||||||
fireEvent.click(screen.getByTestId('close-dsl-modal'))
|
fireEvent.click(screen.getByTestId('close-dsl-modal'))
|
||||||
|
|
||||||
// No modals should be visible
|
|
||||||
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('create-app-modal')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('create-template-dialog')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||||
@ -267,7 +257,6 @@ describe('CreateAppCard', () => {
|
|||||||
render(<CreateAppCard ref={defaultRef} />)
|
render(<CreateAppCard ref={defaultRef} />)
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||||
// This should not throw an error
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
fireEvent.click(screen.getByTestId('success-create-modal'))
|
fireEvent.click(screen.getByTestId('success-create-modal'))
|
||||||
}).not.toThrow()
|
}).not.toThrow()
|
||||||
@ -1,16 +1,8 @@
|
|||||||
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
import type { UrlUpdateEvent } from 'nuqs/adapters/testing'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
/**
|
|
||||||
* Test suite for useAppsQueryState hook
|
|
||||||
*
|
|
||||||
* This hook manages app filtering state through URL search parameters, enabling:
|
|
||||||
* - Bookmarkable filter states (users can share URLs with specific filters active)
|
|
||||||
* - Browser history integration (back/forward buttons work with filters)
|
|
||||||
* - Multiple filter types: tagIDs, keywords, isCreatedByMe
|
|
||||||
*/
|
|
||||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
||||||
import useAppsQueryState from './use-apps-query-state'
|
import useAppsQueryState from '../use-apps-query-state'
|
||||||
|
|
||||||
const renderWithAdapter = (searchParams = '') => {
|
const renderWithAdapter = (searchParams = '') => {
|
||||||
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>()
|
||||||
@ -23,13 +15,11 @@ const renderWithAdapter = (searchParams = '') => {
|
|||||||
return { result, onUrlUpdate }
|
return { result, onUrlUpdate }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Groups scenarios for useAppsQueryState behavior.
|
|
||||||
describe('useAppsQueryState', () => {
|
describe('useAppsQueryState', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Covers the hook return shape and default values.
|
|
||||||
describe('Initialization', () => {
|
describe('Initialization', () => {
|
||||||
it('should expose query and setQuery when initialized', () => {
|
it('should expose query and setQuery when initialized', () => {
|
||||||
const { result } = renderWithAdapter()
|
const { result } = renderWithAdapter()
|
||||||
@ -47,7 +37,6 @@ describe('useAppsQueryState', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Covers parsing of existing URL search params.
|
|
||||||
describe('Parsing search params', () => {
|
describe('Parsing search params', () => {
|
||||||
it('should parse tagIDs when URL includes tagIDs', () => {
|
it('should parse tagIDs when URL includes tagIDs', () => {
|
||||||
const { result } = renderWithAdapter('?tagIDs=tag1;tag2;tag3')
|
const { result } = renderWithAdapter('?tagIDs=tag1;tag2;tag3')
|
||||||
@ -78,7 +67,6 @@ describe('useAppsQueryState', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Covers updates driven by setQuery.
|
|
||||||
describe('Updating query state', () => {
|
describe('Updating query state', () => {
|
||||||
it('should update keywords when setQuery receives keywords', () => {
|
it('should update keywords when setQuery receives keywords', () => {
|
||||||
const { result } = renderWithAdapter()
|
const { result } = renderWithAdapter()
|
||||||
@ -126,7 +114,6 @@ describe('useAppsQueryState', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Covers URL updates triggered by query changes.
|
|
||||||
describe('URL synchronization', () => {
|
describe('URL synchronization', () => {
|
||||||
it('should sync keywords to URL when keywords change', async () => {
|
it('should sync keywords to URL when keywords change', async () => {
|
||||||
const { result, onUrlUpdate } = renderWithAdapter()
|
const { result, onUrlUpdate } = renderWithAdapter()
|
||||||
@ -202,7 +189,6 @@ describe('useAppsQueryState', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Covers decoding and empty values.
|
|
||||||
describe('Edge cases', () => {
|
describe('Edge cases', () => {
|
||||||
it('should treat empty tagIDs as empty list when URL param is empty', () => {
|
it('should treat empty tagIDs as empty list when URL param is empty', () => {
|
||||||
const { result } = renderWithAdapter('?tagIDs=')
|
const { result } = renderWithAdapter('?tagIDs=')
|
||||||
@ -223,7 +209,6 @@ describe('useAppsQueryState', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Covers multi-step updates that mimic real usage.
|
|
||||||
describe('Integration scenarios', () => {
|
describe('Integration scenarios', () => {
|
||||||
it('should keep accumulated filters when updates are sequential', () => {
|
it('should keep accumulated filters when updates are sequential', () => {
|
||||||
const { result } = renderWithAdapter()
|
const { result } = renderWithAdapter()
|
||||||
@ -1,15 +1,6 @@
|
|||||||
/**
|
|
||||||
* Test suite for useDSLDragDrop hook
|
|
||||||
*
|
|
||||||
* This hook provides drag-and-drop functionality for DSL files, enabling:
|
|
||||||
* - File drag detection with visual feedback (dragging state)
|
|
||||||
* - YAML/YML file filtering (only accepts .yaml and .yml files)
|
|
||||||
* - Enable/disable toggle for conditional drag-and-drop
|
|
||||||
* - Cleanup on unmount (removes event listeners)
|
|
||||||
*/
|
|
||||||
import type { Mock } from 'vitest'
|
import type { Mock } from 'vitest'
|
||||||
import { act, renderHook } from '@testing-library/react'
|
import { act, renderHook } from '@testing-library/react'
|
||||||
import { useDSLDragDrop } from './use-dsl-drag-drop'
|
import { useDSLDragDrop } from '../use-dsl-drag-drop'
|
||||||
|
|
||||||
describe('useDSLDragDrop', () => {
|
describe('useDSLDragDrop', () => {
|
||||||
let container: HTMLDivElement
|
let container: HTMLDivElement
|
||||||
@ -26,7 +17,6 @@ describe('useDSLDragDrop', () => {
|
|||||||
document.body.removeChild(container)
|
document.body.removeChild(container)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Helper to create drag events
|
|
||||||
const createDragEvent = (type: string, files: File[] = []) => {
|
const createDragEvent = (type: string, files: File[] = []) => {
|
||||||
const dataTransfer = {
|
const dataTransfer = {
|
||||||
types: files.length > 0 ? ['Files'] : [],
|
types: files.length > 0 ? ['Files'] : [],
|
||||||
@ -50,7 +40,6 @@ describe('useDSLDragDrop', () => {
|
|||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create a mock file
|
|
||||||
const createMockFile = (name: string) => {
|
const createMockFile = (name: string) => {
|
||||||
return new File(['content'], name, { type: 'application/x-yaml' })
|
return new File(['content'], name, { type: 'application/x-yaml' })
|
||||||
}
|
}
|
||||||
@ -147,14 +136,12 @@ describe('useDSLDragDrop', () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// First, enter with files
|
|
||||||
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
||||||
act(() => {
|
act(() => {
|
||||||
container.dispatchEvent(enterEvent)
|
container.dispatchEvent(enterEvent)
|
||||||
})
|
})
|
||||||
expect(result.current.dragging).toBe(true)
|
expect(result.current.dragging).toBe(true)
|
||||||
|
|
||||||
// Then leave with null relatedTarget (leaving container)
|
|
||||||
const leaveEvent = createDragEvent('dragleave')
|
const leaveEvent = createDragEvent('dragleave')
|
||||||
Object.defineProperty(leaveEvent, 'relatedTarget', {
|
Object.defineProperty(leaveEvent, 'relatedTarget', {
|
||||||
value: null,
|
value: null,
|
||||||
@ -180,14 +167,12 @@ describe('useDSLDragDrop', () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// First, enter with files
|
|
||||||
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
||||||
act(() => {
|
act(() => {
|
||||||
container.dispatchEvent(enterEvent)
|
container.dispatchEvent(enterEvent)
|
||||||
})
|
})
|
||||||
expect(result.current.dragging).toBe(true)
|
expect(result.current.dragging).toBe(true)
|
||||||
|
|
||||||
// Then leave but to a child element
|
|
||||||
const leaveEvent = createDragEvent('dragleave')
|
const leaveEvent = createDragEvent('dragleave')
|
||||||
Object.defineProperty(leaveEvent, 'relatedTarget', {
|
Object.defineProperty(leaveEvent, 'relatedTarget', {
|
||||||
value: childElement,
|
value: childElement,
|
||||||
@ -290,14 +275,12 @@ describe('useDSLDragDrop', () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// First, enter with files
|
|
||||||
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
||||||
act(() => {
|
act(() => {
|
||||||
container.dispatchEvent(enterEvent)
|
container.dispatchEvent(enterEvent)
|
||||||
})
|
})
|
||||||
expect(result.current.dragging).toBe(true)
|
expect(result.current.dragging).toBe(true)
|
||||||
|
|
||||||
// Then drop
|
|
||||||
const dropEvent = createDragEvent('drop', [createMockFile('test.yaml')])
|
const dropEvent = createDragEvent('drop', [createMockFile('test.yaml')])
|
||||||
act(() => {
|
act(() => {
|
||||||
container.dispatchEvent(dropEvent)
|
container.dispatchEvent(dropEvent)
|
||||||
@ -409,14 +392,12 @@ describe('useDSLDragDrop', () => {
|
|||||||
{ initialProps: { enabled: true } },
|
{ initialProps: { enabled: true } },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set dragging state
|
|
||||||
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
const enterEvent = createDragEvent('dragenter', [createMockFile('test.yaml')])
|
||||||
act(() => {
|
act(() => {
|
||||||
container.dispatchEvent(enterEvent)
|
container.dispatchEvent(enterEvent)
|
||||||
})
|
})
|
||||||
expect(result.current.dragging).toBe(true)
|
expect(result.current.dragging).toBe(true)
|
||||||
|
|
||||||
// Disable the hook
|
|
||||||
rerender({ enabled: false })
|
rerender({ enabled: false })
|
||||||
expect(result.current.dragging).toBe(false)
|
expect(result.current.dragging).toBe(false)
|
||||||
})
|
})
|
||||||
@ -6,12 +6,10 @@ vi.mock('@/utils/clipboard', () => ({
|
|||||||
writeTextToClipboard: vi.fn().mockResolvedValue(undefined),
|
writeTextToClipboard: vi.fn().mockResolvedValue(undefined),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Suppress expected React act() warnings and jsdom unimplemented API errors
|
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
||||||
|
|
||||||
describe('code.tsx components', () => {
|
describe('code.tsx components', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
// jsdom does not implement scrollBy; mock it to prevent stderr noise
|
// jsdom does not implement scrollBy; mock it to prevent stderr noise
|
||||||
window.scrollBy = vi.fn()
|
window.scrollBy = vi.fn()
|
||||||
@ -20,6 +18,7 @@ describe('code.tsx components', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.runOnlyPendingTimers()
|
vi.runOnlyPendingTimers()
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Code', () => {
|
describe('Code', () => {
|
||||||
|
|||||||
@ -2,9 +2,6 @@ import { act, render, screen } from '@testing-library/react'
|
|||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import InputCopy from '../input-copy'
|
import InputCopy from '../input-copy'
|
||||||
|
|
||||||
// Suppress expected React act() warnings from CopyFeedback timer-based state updates
|
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
||||||
|
|
||||||
async function renderAndFlush(ui: React.ReactElement) {
|
async function renderAndFlush(ui: React.ReactElement) {
|
||||||
const result = render(ui)
|
const result = render(ui)
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@ -18,6 +15,7 @@ const execCommandMock = vi.fn().mockReturnValue(true)
|
|||||||
describe('InputCopy', () => {
|
describe('InputCopy', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
execCommandMock.mockReturnValue(true)
|
execCommandMock.mockReturnValue(true)
|
||||||
document.execCommand = execCommandMock
|
document.execCommand = execCommandMock
|
||||||
@ -26,6 +24,7 @@ describe('InputCopy', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.runOnlyPendingTimers()
|
vi.runOnlyPendingTimers()
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
|
|||||||
@ -3,9 +3,6 @@ import userEvent from '@testing-library/user-event'
|
|||||||
import { afterEach } from 'vitest'
|
import { afterEach } from 'vitest'
|
||||||
import SecretKeyModal from '../secret-key-modal'
|
import SecretKeyModal from '../secret-key-modal'
|
||||||
|
|
||||||
// Suppress expected React act() warnings from Headless UI Dialog transitions and async API state updates
|
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
||||||
|
|
||||||
async function renderModal(ui: React.ReactElement) {
|
async function renderModal(ui: React.ReactElement) {
|
||||||
const result = render(ui)
|
const result = render(ui)
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@ -91,6 +88,8 @@ describe('SecretKeyModal', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
// Suppress expected React act() warnings from Headless UI Dialog transitions and async API state updates
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||||
mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' })
|
mockCurrentWorkspace.mockReturnValue({ id: 'workspace-1', name: 'Test Workspace' })
|
||||||
mockIsCurrentWorkspaceManager.mockReturnValue(true)
|
mockIsCurrentWorkspaceManager.mockReturnValue(true)
|
||||||
@ -104,6 +103,7 @@ describe('SecretKeyModal', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.runOnlyPendingTimers()
|
vi.runOnlyPendingTimers()
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('rendering when shown', () => {
|
describe('rendering when shown', () => {
|
||||||
|
|||||||
@ -4,9 +4,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import TryApp from '../index'
|
import TryApp from '../index'
|
||||||
import { TypeEnum } from '../tab'
|
import { TypeEnum } from '../tab'
|
||||||
|
|
||||||
// Suppress expected React act() warnings from internal async state updates
|
|
||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
||||||
|
|
||||||
vi.mock('@/config', async (importOriginal) => {
|
vi.mock('@/config', async (importOriginal) => {
|
||||||
const actual = await importOriginal() as object
|
const actual = await importOriginal() as object
|
||||||
return {
|
return {
|
||||||
@ -91,6 +88,9 @@ const createMockAppDetail = (mode: string = 'chat'): TryAppInfo => ({
|
|||||||
|
|
||||||
describe('TryApp (main index.tsx)', () => {
|
describe('TryApp (main index.tsx)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Suppress expected React act() warnings from internal async state updates
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
mockUseGetTryAppInfo.mockReturnValue({
|
mockUseGetTryAppInfo.mockReturnValue({
|
||||||
data: createMockAppDetail(),
|
data: createMockAppDetail(),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@ -99,7 +99,7 @@ describe('TryApp (main index.tsx)', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
vi.clearAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('loading state', () => {
|
describe('loading state', () => {
|
||||||
|
|||||||
@ -23,6 +23,10 @@ describe('VersionMismatchModal', () => {
|
|||||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
describe('rendering', () => {
|
describe('rendering', () => {
|
||||||
it('should render dialog when isShow is true', () => {
|
it('should render dialog when isShow is true', () => {
|
||||||
render(<VersionMismatchModal {...defaultProps} />)
|
render(<VersionMismatchModal {...defaultProps} />)
|
||||||
|
|||||||
@ -83,7 +83,7 @@ describe('usePipelineInit', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.restoreAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('hook initialization', () => {
|
describe('hook initialization', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user