mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 21:35:45 +08:00
test(web): replace chat and plugin list owner doubles (#44725)
This commit is contained in:
1 parent
2d78a7846d
commit
3c9993be3d
4 files changed
+578
-840
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { ChatConfig } from '../../types'
|
||||
import type { ChatWithHistoryContextValue } from '../context'
|
||||
import type { AppData, ConversationItem } from '@/models/share'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import { shareQueryKeys } from '@/service/use-share'
|
||||
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { createChatConfigFixture } from '@/test/fixtures/chat'
|
||||
import { CONVERSATION_ID_INFO, TAB_CONVERSATION_ID_INFO } from '../../constants'
|
||||
import { ChatWithHistoryContext } from '../context'
|
||||
import { useChatWithHistory } from '../hooks'
|
||||
|
||||
export const conversation: ConversationItem = {
|
||||
id: 'conversation-1',
|
||||
name: 'My Chat',
|
||||
inputs: {},
|
||||
introduction: '',
|
||||
}
|
||||
|
||||
export function renderChatHistory(
|
||||
ui: ReactElement,
|
||||
{
|
||||
currentConversationId = conversation.id,
|
||||
conversations = [conversation],
|
||||
pinnedConversations = [],
|
||||
appInfo = {
|
||||
app_id: 'app-1',
|
||||
end_user_id: 'user-1',
|
||||
site: { title: 'Test Chat' },
|
||||
custom_config: null,
|
||||
},
|
||||
appParams = createChatConfigFixture(),
|
||||
sidebarCollapsed = true,
|
||||
ownerProps = {},
|
||||
}: {
|
||||
currentConversationId?: string
|
||||
conversations?: ConversationItem[]
|
||||
pinnedConversations?: ConversationItem[]
|
||||
appInfo?: AppData
|
||||
appParams?: ChatConfig
|
||||
sidebarCollapsed?: boolean
|
||||
// These are caller-owned display inputs; all conversation commands come from the real owner.
|
||||
ownerProps?: Pick<Partial<ChatWithHistoryContextValue>, 'isMobile' | 'isResponding'>
|
||||
} = {},
|
||||
) {
|
||||
window.history.replaceState({}, '', '/')
|
||||
const selection = JSON.stringify({
|
||||
[appInfo.app_id]: { [appInfo.end_user_id || 'DEFAULT']: currentConversationId },
|
||||
})
|
||||
localStorage.setItem(CONVERSATION_ID_INFO, selection)
|
||||
sessionStorage.setItem(TAB_CONVERSATION_ID_INFO, selection)
|
||||
localStorage.setItem('webappSidebarCollapse', sidebarCollapsed ? 'collapsed' : 'expanded')
|
||||
useWebAppStore.setState({ appInfo, appParams, appMeta: { tool_icons: {} } })
|
||||
const queryClient = createConsoleQueryClient()
|
||||
const listKey = (pinned: boolean) =>
|
||||
shareQueryKeys.conversationList(null, {
|
||||
appSourceType: AppSourceType.webApp,
|
||||
appId: appInfo.app_id,
|
||||
pinned,
|
||||
limit: 100,
|
||||
})
|
||||
queryClient.setQueryData(listKey(false), { data: conversations, has_more: false, limit: 100 })
|
||||
queryClient.setQueryData(listKey(true), {
|
||||
data: pinnedConversations,
|
||||
has_more: false,
|
||||
limit: 100,
|
||||
})
|
||||
function Owner() {
|
||||
const value = useChatWithHistory()
|
||||
return (
|
||||
<ChatWithHistoryContext.Provider value={{ ...value, isMobile: false, ...ownerProps }}>
|
||||
{ui}
|
||||
</ChatWithHistoryContext.Provider>
|
||||
)
|
||||
}
|
||||
return {
|
||||
...renderWithConsoleQuery(<Owner />, { queryClient }),
|
||||
webAppStore: useWebAppStore,
|
||||
listKey,
|
||||
}
|
||||
}
|
||||
+205
-402
@@ -1,423 +1,226 @@
|
||||
import type { ChatConfig } from '../../types'
|
||||
import type { ChatWithHistoryContextValue } from '../context'
|
||||
import type { AppData, AppMeta } from '@/models/share'
|
||||
import { screen, waitFor, within } from '@testing-library/react'
|
||||
import { act, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { ChatWithHistoryContext } from '../context'
|
||||
import {
|
||||
AppSourceType,
|
||||
delConversation,
|
||||
fetchChatList,
|
||||
fetchConversations,
|
||||
pinConversation,
|
||||
renameConversation,
|
||||
unpinConversation,
|
||||
} from '@/service/share'
|
||||
import { createDeferred } from '@/test/deferred'
|
||||
import { createChatConfigFixture } from '@/test/fixtures/chat'
|
||||
import { CONVERSATION_ID_INFO, TAB_CONVERSATION_ID_INFO } from '../../constants'
|
||||
import HeaderInMobile from '../header-in-mobile'
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: vi.fn(),
|
||||
MediaType: {
|
||||
mobile: 'mobile',
|
||||
tablet: 'tablet',
|
||||
pc: 'pc',
|
||||
},
|
||||
}))
|
||||
import { conversation, renderChatHistory } from './chat-history-test-env'
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
})),
|
||||
usePathname: vi.fn(() => '/'),
|
||||
useSearchParams: vi.fn(() => new URLSearchParams()),
|
||||
useParams: vi.fn(() => ({})),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
vi.mock('@/service/share', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/share')>()),
|
||||
fetchChatList: vi.fn(),
|
||||
fetchConversations: vi.fn(),
|
||||
pinConversation: vi.fn(),
|
||||
unpinConversation: vi.fn(),
|
||||
renameConversation: vi.fn(),
|
||||
delConversation: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockAppData: AppData = {
|
||||
app_id: 'test-app',
|
||||
custom_config: null,
|
||||
site: {
|
||||
title: 'Test Chat',
|
||||
chat_color_theme: 'blue',
|
||||
},
|
||||
}
|
||||
const defaultContextValue: ChatWithHistoryContextValue = {
|
||||
appData: mockAppData,
|
||||
currentConversationId: '',
|
||||
currentConversationItem: undefined,
|
||||
inputsForms: [],
|
||||
handlePinConversation: vi.fn(),
|
||||
handleUnpinConversation: vi.fn(),
|
||||
handleDeleteConversation: vi.fn(),
|
||||
handleRenameConversation: vi.fn(),
|
||||
handleNewConversation: vi.fn(),
|
||||
handleNewConversationInputsChange: vi.fn(),
|
||||
handleStartChat: vi.fn(),
|
||||
handleChangeConversation: vi.fn(),
|
||||
handleNewConversationCompleted: vi.fn(),
|
||||
handleFeedback: vi.fn(),
|
||||
sidebarCollapseState: false,
|
||||
handleSidebarCollapse: vi.fn(),
|
||||
pinnedConversationList: [],
|
||||
conversationList: [],
|
||||
isInstalledApp: false,
|
||||
currentChatInstanceRef: {
|
||||
current: { handleStop: vi.fn() },
|
||||
} as ChatWithHistoryContextValue['currentChatInstanceRef'],
|
||||
setIsResponding: vi.fn(),
|
||||
setClearChatList: vi.fn(),
|
||||
appParams: {
|
||||
system_parameters: {
|
||||
audio_file_size_limit: 10,
|
||||
file_size_limit: 10,
|
||||
image_file_size_limit: 10,
|
||||
video_file_size_limit: 10,
|
||||
workflow_file_upload_limit: 10,
|
||||
},
|
||||
more_like_this: { enabled: false },
|
||||
} as ChatConfig,
|
||||
appMeta: { tool_icons: {} } as AppMeta,
|
||||
appPrevChatTree: [],
|
||||
newConversationInputs: {},
|
||||
newConversationInputsRef: { current: {} },
|
||||
appChatListDataLoading: false,
|
||||
chatShouldReloadKey: '',
|
||||
isMobile: true,
|
||||
currentConversationInputs: null,
|
||||
setCurrentConversationInputs: vi.fn(),
|
||||
allInputsHidden: false,
|
||||
conversationRenaming: false,
|
||||
}
|
||||
const setup = (options: Parameters<typeof renderChatHistory>[1] = {}) =>
|
||||
renderChatHistory(<HeaderInMobile />, { ...options, ownerProps: { isMobile: true } })
|
||||
|
||||
let context = defaultContextValue
|
||||
const setContext = (value: ChatWithHistoryContextValue) => {
|
||||
context = value
|
||||
}
|
||||
const render = (ui: React.ReactElement) =>
|
||||
renderWithConsoleQuery(
|
||||
<ChatWithHistoryContext.Provider value={context}>{ui}</ChatWithHistoryContext.Provider>,
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchChatList).mockResolvedValue({ data: [] })
|
||||
vi.mocked(fetchConversations).mockResolvedValue({ data: [], has_more: false, limit: 100 })
|
||||
vi.mocked(pinConversation).mockResolvedValue(undefined)
|
||||
vi.mocked(unpinConversation).mockResolvedValue(undefined)
|
||||
vi.mocked(renameConversation).mockResolvedValue(undefined)
|
||||
vi.mocked(delConversation).mockResolvedValue(undefined)
|
||||
})
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(CONVERSATION_ID_INFO)
|
||||
sessionStorage.removeItem(TAB_CONVERSATION_ID_INFO)
|
||||
localStorage.removeItem('webappSidebarCollapse')
|
||||
})
|
||||
|
||||
async function operate(user: ReturnType<typeof userEvent.setup>, action: string) {
|
||||
await user.click(await screen.findByRole('button', { name: 'My Chat' }))
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: `explore.sidebar.action.${action}` }),
|
||||
)
|
||||
}
|
||||
|
||||
describe('HeaderInMobile', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
|
||||
setContext(defaultContextValue)
|
||||
it('contains sidebar focus and restores the trigger after Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup({ currentConversationId: '' })
|
||||
const trigger = screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })
|
||||
const backgroundMore = screen.getByRole('button', { name: 'common.operation.more' })
|
||||
await user.click(trigger)
|
||||
const sidebar = await screen.findByRole('dialog', { name: 'Test Chat' })
|
||||
await waitFor(() => expect(sidebar).toContainElement(document.activeElement as HTMLElement))
|
||||
expect(screen.queryAllByRole('button', { name: 'common.operation.more' })).not.toContain(
|
||||
backgroundMore,
|
||||
)
|
||||
await user.tab({ shift: true })
|
||||
expect(sidebar).toContainElement(document.activeElement as HTMLElement)
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
})
|
||||
|
||||
it('provides a keyboard-operable sidebar close button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup({ currentConversationId: '' })
|
||||
const trigger = screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })
|
||||
await user.click(trigger)
|
||||
const sidebar = await screen.findByRole('dialog', { name: 'Test Chat' })
|
||||
const close = within(sidebar).getByRole('button', { name: 'common.operation.close' })
|
||||
close.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
})
|
||||
|
||||
it('opens named modal chat settings and closes with Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup({
|
||||
appParams: createChatConfigFixture({
|
||||
user_input_form: [{ 'text-input': { variable: 'topic', label: 'Topic', required: true } }],
|
||||
}),
|
||||
})
|
||||
const trigger = screen.getByRole('button', { name: 'common.operation.more' })
|
||||
await user.click(trigger)
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'share.chat.viewChatSettings' }))
|
||||
const settings = await screen.findByRole('dialog', { name: 'share.chat.chatSettingsTitle' })
|
||||
await waitFor(() => expect(settings).toContainElement(document.activeElement as HTMLElement))
|
||||
expect(within(settings).getByRole('textbox', { name: /Topic/ })).toBeVisible()
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
})
|
||||
|
||||
it('should render title when no conversation', () => {
|
||||
render(<HeaderInMobile />)
|
||||
expect(screen.getByText('Test Chat'))!.toBeInTheDocument()
|
||||
it('hides chat settings when the application has no input forms', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
expect(
|
||||
screen.queryByRole('menuitem', { name: 'share.chat.viewChatSettings' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resets the active conversation from the More menu and persists New Chat', async () => {
|
||||
const user = userEvent.setup()
|
||||
setup()
|
||||
expect(await screen.findByRole('button', { name: 'My Chat' })).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'share.chat.resetChat' }))
|
||||
expect(await screen.findByText('Test Chat')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'My Chat' })).not.toBeInTheDocument()
|
||||
expect(JSON.parse(sessionStorage.getItem(TAB_CONVERSATION_ID_INFO)!)).toEqual({
|
||||
'app-1': { 'user-1': '' },
|
||||
})
|
||||
|
||||
it('should render conversation name when active', async () => {
|
||||
setContext({
|
||||
...defaultContextValue,
|
||||
currentConversationId: '1',
|
||||
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
|
||||
})
|
||||
|
||||
render(<HeaderInMobile />)
|
||||
expect(await screen.findByText('Conv 1'))!.toBeInTheDocument()
|
||||
expect(JSON.parse(localStorage.getItem(CONVERSATION_ID_INFO)!)).toEqual({
|
||||
'app-1': { 'user-1': '' },
|
||||
})
|
||||
})
|
||||
|
||||
it('contains sidebar focus and restores the trigger after Escape', async () => {
|
||||
it.each([false, true])(
|
||||
'refreshes the available pin action after saving pinned=%s',
|
||||
async (pinned) => {
|
||||
const user = userEvent.setup()
|
||||
render(<HeaderInMobile />)
|
||||
const trigger = screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })
|
||||
const backgroundMore = screen.getByRole('button', { name: 'common.operation.more' })
|
||||
await user.click(trigger)
|
||||
const sidebar = await screen.findByRole('dialog', { name: 'Test Chat' })
|
||||
await waitFor(() => expect(sidebar).toContainElement(document.activeElement as HTMLElement))
|
||||
expect(screen.queryAllByRole('button', { name: 'common.operation.more' })).not.toContain(
|
||||
backgroundMore,
|
||||
const { queryClient, listKey } = setup({
|
||||
conversations: pinned ? [] : [conversation],
|
||||
pinnedConversations: pinned ? [conversation] : [],
|
||||
})
|
||||
vi.mocked(fetchConversations).mockImplementation(
|
||||
async (_source, _app, _last, requestedPinned) => ({
|
||||
data: requestedPinned === !pinned ? [conversation] : [],
|
||||
has_more: false,
|
||||
limit: 100,
|
||||
}),
|
||||
)
|
||||
await operate(user, pinned ? 'unpin' : 'pin')
|
||||
await waitFor(() =>
|
||||
expect(queryClient.getQueryData(listKey(!pinned))).toMatchObject({ data: [conversation] }),
|
||||
)
|
||||
await user.tab({ shift: true })
|
||||
expect(sidebar).toContainElement(document.activeElement as HTMLElement)
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
})
|
||||
|
||||
it('provides a keyboard-operable sidebar close button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<HeaderInMobile />)
|
||||
const trigger = screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })
|
||||
await user.click(trigger)
|
||||
const sidebar = await screen.findByRole('dialog', { name: 'Test Chat' })
|
||||
const close = within(sidebar).getByRole('button', { name: 'common.operation.close' })
|
||||
close.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
})
|
||||
|
||||
it('opens named modal chat settings and closes with Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
setContext({
|
||||
...defaultContextValue,
|
||||
inputsForms: [{ variable: 'test', label: 'Test', type: 'text', required: true }],
|
||||
})
|
||||
render(<HeaderInMobile />)
|
||||
const trigger = screen.getByRole('button', { name: 'common.operation.more' })
|
||||
await user.click(trigger)
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'share.chat.viewChatSettings' }))
|
||||
const settings = await screen.findByRole('dialog', { name: 'share.chat.chatSettingsTitle' })
|
||||
await waitFor(() => expect(settings).toContainElement(document.activeElement as HTMLElement))
|
||||
await user.click(await screen.findByRole('button', { name: 'My Chat' }))
|
||||
expect(
|
||||
within(settings).getByRole('button', { name: 'common.operation.close' }),
|
||||
).toBeInTheDocument()
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
})
|
||||
|
||||
it('should hide chat settings option when no input forms', async () => {
|
||||
await screen.findByRole('menuitem', {
|
||||
name: `explore.sidebar.action.${pinned ? 'pin' : 'unpin'}`,
|
||||
}),
|
||||
).toBeVisible()
|
||||
expect(pinned ? unpinConversation : pinConversation).toHaveBeenCalledExactlyOnceWith(
|
||||
AppSourceType.webApp,
|
||||
'app-1',
|
||||
'conversation-1',
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('renames the conversation after saving, with pending state blocking duplicate submissions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const request = createDeferred<Awaited<ReturnType<typeof renameConversation>>>()
|
||||
vi.mocked(renameConversation).mockReturnValue(request.promise)
|
||||
setup()
|
||||
await operate(user, 'rename')
|
||||
const input = await screen.findByRole('textbox', { name: 'common.chat.conversationName' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'New Name')
|
||||
const save = screen.getByRole('button', { name: 'common.operation.save' })
|
||||
await user.click(save)
|
||||
expect(save).toHaveAttribute('aria-disabled', 'true')
|
||||
await user.click(save)
|
||||
expect(renameConversation).toHaveBeenCalledExactlyOnceWith(
|
||||
AppSourceType.webApp,
|
||||
'app-1',
|
||||
'conversation-1',
|
||||
'New Name',
|
||||
)
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
await act(async () => request.resolve(undefined))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: 'New Name' })).toBeVisible()
|
||||
})
|
||||
|
||||
it.each(['rename', 'delete'])(
|
||||
'cancels %s and preserves the current conversation',
|
||||
async (action) => {
|
||||
const user = userEvent.setup()
|
||||
setContext({
|
||||
...defaultContextValue,
|
||||
inputsForms: [],
|
||||
})
|
||||
|
||||
render(<HeaderInMobile />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.more' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/share\.chat\.viewChatSettings/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle new conversation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleNewConversation = vi.fn()
|
||||
setContext({
|
||||
...defaultContextValue,
|
||||
handleNewConversation,
|
||||
})
|
||||
|
||||
render(<HeaderInMobile />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.more' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/share\.chat\.resetChat/i))!.toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText(/share\.chat\.resetChat/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleNewConversation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle pin conversation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handlePin = vi.fn()
|
||||
setContext({
|
||||
...defaultContextValue,
|
||||
currentConversationId: '1',
|
||||
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
|
||||
handlePinConversation: handlePin,
|
||||
pinnedConversationList: [],
|
||||
})
|
||||
|
||||
render(<HeaderInMobile />)
|
||||
|
||||
await user.click(await screen.findByText('Conv 1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/explore\.sidebar\.action\.pin/i))!.toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText(/explore\.sidebar\.action\.pin/i))
|
||||
expect(handlePin).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
it('should handle unpin conversation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleUnpin = vi.fn()
|
||||
setContext({
|
||||
...defaultContextValue,
|
||||
currentConversationId: '1',
|
||||
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
|
||||
handleUnpinConversation: handleUnpin,
|
||||
pinnedConversationList: [{ id: '1', name: 'Conv 1', inputs: null, introduction: '' }],
|
||||
})
|
||||
|
||||
render(<HeaderInMobile />)
|
||||
|
||||
await user.click(await screen.findByText('Conv 1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/explore\.sidebar\.action\.unpin/i))!.toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText(/explore\.sidebar\.action\.unpin/i))
|
||||
expect(handleUnpin).toHaveBeenCalledWith('1')
|
||||
})
|
||||
|
||||
it('should handle rename conversation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleRename = vi.fn()
|
||||
setContext({
|
||||
...defaultContextValue,
|
||||
currentConversationId: '1',
|
||||
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
|
||||
handleRenameConversation: handleRename,
|
||||
pinnedConversationList: [],
|
||||
})
|
||||
|
||||
render(<HeaderInMobile />)
|
||||
await user.click(await screen.findByText('Conv 1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/explore\.sidebar\.action\.rename/i))!.toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText(/explore\.sidebar\.action\.rename/i))
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
const input = screen.getByDisplayValue('Conv 1')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'New Name')
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: /common\.operation\.save/i })
|
||||
await user.click(saveButton)
|
||||
expect(handleRename).toHaveBeenCalledWith('1', 'New Name', expect.any(Object))
|
||||
})
|
||||
|
||||
it('should cancel rename conversation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleRename = vi.fn()
|
||||
setContext({
|
||||
...defaultContextValue,
|
||||
currentConversationId: '1',
|
||||
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
|
||||
handleRenameConversation: handleRename,
|
||||
pinnedConversationList: [],
|
||||
})
|
||||
|
||||
render(<HeaderInMobile />)
|
||||
await user.click(await screen.findByText('Conv 1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/explore\.sidebar\.action\.rename/i))!.toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText(/explore\.sidebar\.action\.rename/i))
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /common\.operation\.cancel/i })
|
||||
await user.click(cancelButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(handleRename).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show loading state while renaming', async () => {
|
||||
const user = userEvent.setup()
|
||||
setContext({
|
||||
...defaultContextValue,
|
||||
currentConversationId: '1',
|
||||
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
|
||||
handleRenameConversation: vi.fn(),
|
||||
conversationRenaming: true, // Loading state
|
||||
pinnedConversationList: [],
|
||||
})
|
||||
|
||||
render(<HeaderInMobile />)
|
||||
await user.click(await screen.findByText('Conv 1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/explore\.sidebar\.action\.rename/i))!.toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText(/explore\.sidebar\.action\.rename/i))
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
const save = screen.getByRole('button', { name: /common\.operation\.save/i })
|
||||
expect(save).toHaveAttribute('aria-disabled', 'true')
|
||||
await user.click(save)
|
||||
expect(context.handleRenameConversation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle delete conversation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleDelete = vi.fn()
|
||||
setContext({
|
||||
...defaultContextValue,
|
||||
currentConversationId: '1',
|
||||
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
|
||||
handleDeleteConversation: handleDelete,
|
||||
pinnedConversationList: [],
|
||||
})
|
||||
|
||||
render(<HeaderInMobile />)
|
||||
await user.click(await screen.findByText('Conv 1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/explore\.sidebar\.action\.delete/i))!.toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText(/explore\.sidebar\.action\.delete/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/share\.chat\.deleteConversation\.title/i)[0])!.toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.confirm/i }))
|
||||
expect(handleDelete).toHaveBeenCalledWith('1', expect.any(Object))
|
||||
})
|
||||
|
||||
it('should cancel delete conversation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleDelete = vi.fn()
|
||||
setContext({
|
||||
...defaultContextValue,
|
||||
currentConversationId: '1',
|
||||
currentConversationItem: { id: '1', name: 'Conv 1', inputs: null, introduction: '' },
|
||||
handleDeleteConversation: handleDelete,
|
||||
pinnedConversationList: [],
|
||||
})
|
||||
|
||||
render(<HeaderInMobile />)
|
||||
await user.click(await screen.findByText('Conv 1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/explore\.sidebar\.action\.delete/i))!.toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText(/explore\.sidebar\.action\.delete/i))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/share\.chat\.deleteConversation\.title/i)[0])!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/share\.chat\.deleteConversation\.title/i)).not.toBeInTheDocument()
|
||||
})
|
||||
expect(handleDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use empty string fallback for rename modal name', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleRename = vi.fn()
|
||||
setContext({
|
||||
...defaultContextValue,
|
||||
currentConversationId: '1',
|
||||
currentConversationItem: { id: '1', name: '', inputs: null, introduction: '' },
|
||||
handleRenameConversation: handleRename,
|
||||
pinnedConversationList: [],
|
||||
})
|
||||
|
||||
render(<HeaderInMobile />)
|
||||
const operationTrigger = screen
|
||||
.getAllByRole('button')
|
||||
.find(
|
||||
(button) => button.getAttribute('aria-haspopup') === 'menu' && button.textContent === '',
|
||||
)!
|
||||
await user.click(operationTrigger)
|
||||
await user.click(
|
||||
await screen.findByText(/explore\.sidebar\.action\.rename|sidebar\.action\.rename/i),
|
||||
setup()
|
||||
await operate(user, action)
|
||||
if (action === 'rename') await user.type(await screen.findByRole('textbox'), ' unsaved')
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.cancel' }))
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole(action === 'rename' ? 'dialog' : 'alertdialog'),
|
||||
).not.toBeInTheDocument(),
|
||||
)
|
||||
|
||||
const input = await screen.findByRole('textbox')
|
||||
expect(input)!.toHaveValue('')
|
||||
|
||||
await user.type(input, 'Renamed from empty')
|
||||
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
|
||||
expect(handleRename).toHaveBeenCalledWith('1', 'Renamed from empty', expect.any(Object))
|
||||
expect(screen.getByRole('button', { name: 'My Chat' })).toBeVisible()
|
||||
expect(renameConversation).not.toHaveBeenCalled()
|
||||
expect(delConversation).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('deletes the selected conversation and returns to the application title after refreshing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { queryClient, listKey } = setup()
|
||||
await operate(user, 'delete')
|
||||
expect(delConversation).not.toHaveBeenCalled()
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Test Chat')).toBeVisible()
|
||||
await waitFor(() => expect(queryClient.getQueryData(listKey(false))).toMatchObject({ data: [] }))
|
||||
expect(delConversation).toHaveBeenCalledExactlyOnceWith(
|
||||
AppSourceType.webApp,
|
||||
'app-1',
|
||||
'conversation-1',
|
||||
)
|
||||
expect(JSON.parse(sessionStorage.getItem(TAB_CONVERSATION_ID_INFO)!)).toEqual({
|
||||
'app-1': { 'user-1': '' },
|
||||
})
|
||||
})
|
||||
@@ -1,350 +1,195 @@
|
||||
import type { ChatWithHistoryContextValue } from '../../context'
|
||||
import type { AppData, ConversationItem } from '@/models/share'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { ChatWithHistoryContext, useChatWithHistoryContext } from '../../context'
|
||||
import {
|
||||
AppSourceType,
|
||||
delConversation,
|
||||
fetchChatList,
|
||||
fetchConversations,
|
||||
pinConversation,
|
||||
renameConversation,
|
||||
unpinConversation,
|
||||
} from '@/service/share'
|
||||
import { createChatConfigFixture } from '@/test/fixtures/chat'
|
||||
import { CONVERSATION_ID_INFO, TAB_CONVERSATION_ID_INFO } from '../../../constants'
|
||||
import { conversation, renderChatHistory } from '../../__tests__/chat-history-test-env'
|
||||
import Header from '../index'
|
||||
|
||||
const mockAppData: AppData = {
|
||||
app_id: 'app-1',
|
||||
site: {
|
||||
title: 'Test App',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#fff',
|
||||
icon_url: '',
|
||||
},
|
||||
end_user_id: 'user-1',
|
||||
custom_config: null,
|
||||
can_replace_logo: false,
|
||||
}
|
||||
vi.mock('@/service/share', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/share')>()),
|
||||
fetchChatList: vi.fn(),
|
||||
fetchConversations: vi.fn(),
|
||||
pinConversation: vi.fn(),
|
||||
unpinConversation: vi.fn(),
|
||||
renameConversation: vi.fn(),
|
||||
delConversation: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockContextDefaults: Partial<ChatWithHistoryContextValue> = {
|
||||
appData: mockAppData,
|
||||
currentConversationId: '',
|
||||
currentConversationItem: undefined,
|
||||
inputsForms: [],
|
||||
pinnedConversationList: [],
|
||||
handlePinConversation: vi.fn(),
|
||||
handleUnpinConversation: vi.fn(),
|
||||
handleRenameConversation: vi.fn(),
|
||||
handleDeleteConversation: vi.fn(),
|
||||
handleNewConversation: vi.fn(),
|
||||
sidebarCollapseState: true,
|
||||
handleSidebarCollapse: vi.fn(),
|
||||
isResponding: false,
|
||||
conversationRenaming: false,
|
||||
}
|
||||
|
||||
const conversation = (overrides: Partial<ConversationItem> = {}): ConversationItem => ({
|
||||
id: 'conv-1',
|
||||
name: 'My Chat',
|
||||
inputs: {},
|
||||
introduction: '',
|
||||
...overrides,
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchChatList).mockResolvedValue({ data: [] })
|
||||
vi.mocked(fetchConversations).mockResolvedValue({ data: [], has_more: false, limit: 100 })
|
||||
vi.mocked(pinConversation).mockResolvedValue(undefined)
|
||||
vi.mocked(unpinConversation).mockResolvedValue(undefined)
|
||||
vi.mocked(renameConversation).mockResolvedValue(undefined)
|
||||
vi.mocked(delConversation).mockResolvedValue(undefined)
|
||||
})
|
||||
const setup = (overrides: Partial<ChatWithHistoryContextValue> = {}) => {
|
||||
function Provider() {
|
||||
const defaults = useChatWithHistoryContext()
|
||||
return (
|
||||
<ChatWithHistoryContext.Provider
|
||||
value={{ ...defaults, ...mockContextDefaults, ...overrides }}
|
||||
>
|
||||
<Header />
|
||||
</ChatWithHistoryContext.Provider>
|
||||
)
|
||||
}
|
||||
return render(<Provider />)
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(CONVERSATION_ID_INFO)
|
||||
sessionStorage.removeItem(TAB_CONVERSATION_ID_INFO)
|
||||
localStorage.removeItem('webappSidebarCollapse')
|
||||
})
|
||||
|
||||
async function operate(user: ReturnType<typeof userEvent.setup>, action: string) {
|
||||
await user.click(await screen.findByRole('button', { name: 'My Chat' }))
|
||||
await user.click(
|
||||
await screen.findByRole('menuitem', { name: `explore.sidebar.action.${action}` }),
|
||||
)
|
||||
}
|
||||
|
||||
describe('Header Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
it('resets the selected conversation and persists New Chat from the reset button', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderChatHistory(<Header />)
|
||||
await user.click(screen.getByRole('button', { name: 'share.chat.resetChat' }))
|
||||
expect(await screen.findByText('Test Chat')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'My Chat' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'share.chat.resetChat' })).not.toBeInTheDocument()
|
||||
expect(JSON.parse(sessionStorage.getItem(TAB_CONVERSATION_ID_INFO)!)).toEqual({
|
||||
'app-1': { 'user-1': '' },
|
||||
})
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('opens chat settings with the conversation inputs', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockConv = conversation()
|
||||
setup({
|
||||
currentConversationId: 'conv-1',
|
||||
currentConversationItem: mockConv,
|
||||
inputsForms: [
|
||||
{ type: InputVarType.textInput, label: 'Topic', variable: 'topic', required: false },
|
||||
],
|
||||
currentConversationInputs: { topic: 'Travel' },
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'share.chat.viewChatSettings' }))
|
||||
expect(screen.getByRole('textbox', { name: 'Topic' })).toHaveValue('Travel')
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('textbox', { name: 'Topic' })).not.toBeInTheDocument()
|
||||
})
|
||||
expect(JSON.parse(localStorage.getItem(CONVERSATION_ID_INFO)!)).toEqual({
|
||||
'app-1': { 'user-1': '' },
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should handle new conversation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleNewConversation = vi.fn()
|
||||
setup({ handleNewConversation, sidebarCollapseState: true, currentConversationId: 'conv-1' })
|
||||
it('expands and persists the sidebar, removing the collapsed-header actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderChatHistory(<Header />)
|
||||
await user.click(screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' }))
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'layout.sidebar.expandSidebar' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'My Chat' })).not.toBeInTheDocument()
|
||||
expect(localStorage.getItem('webappSidebarCollapse')).toBe('expanded')
|
||||
})
|
||||
|
||||
const resetChatBtn = screen.getByRole('button', { name: 'share.chat.resetChat' })
|
||||
await user.click(resetChatBtn)
|
||||
|
||||
expect(handleNewConversation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle sidebar toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleSidebarCollapse = vi.fn()
|
||||
setup({ handleSidebarCollapse, sidebarCollapseState: true })
|
||||
|
||||
const sidebarBtn = screen.getByRole('button', { name: 'layout.sidebar.expandSidebar' })
|
||||
await user.click(sidebarBtn)
|
||||
|
||||
expect(handleSidebarCollapse).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should render operation menu and handle pin', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handlePinConversation = vi.fn()
|
||||
const mockConv = conversation()
|
||||
setup({
|
||||
currentConversationId: 'conv-1',
|
||||
currentConversationItem: mockConv,
|
||||
handlePinConversation,
|
||||
sidebarCollapseState: true,
|
||||
})
|
||||
|
||||
const trigger = screen.getByText('My Chat')
|
||||
await user.click(trigger)
|
||||
|
||||
const pinBtn = await screen.findByText('explore.sidebar.action.pin')
|
||||
expect(pinBtn)!.toBeInTheDocument()
|
||||
|
||||
await user.click(pinBtn)
|
||||
|
||||
expect(handlePinConversation).toHaveBeenCalledWith('conv-1')
|
||||
})
|
||||
|
||||
it('should handle unpin', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleUnpinConversation = vi.fn()
|
||||
const mockConv = conversation()
|
||||
setup({
|
||||
currentConversationId: 'conv-1',
|
||||
currentConversationItem: mockConv,
|
||||
handleUnpinConversation,
|
||||
pinnedConversationList: [conversation()],
|
||||
sidebarCollapseState: true,
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('My Chat'))
|
||||
|
||||
const unpinBtn = await screen.findByText('explore.sidebar.action.unpin')
|
||||
await user.click(unpinBtn)
|
||||
|
||||
expect(handleUnpinConversation).toHaveBeenCalledWith('conv-1')
|
||||
})
|
||||
|
||||
it('should handle rename cancellation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockConv = conversation()
|
||||
setup({
|
||||
currentConversationId: 'conv-1',
|
||||
currentConversationItem: mockConv,
|
||||
sidebarCollapseState: true,
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('My Chat'))
|
||||
|
||||
const renameMenuBtn = await screen.findByText('explore.sidebar.action.rename')
|
||||
await user.click(renameMenuBtn)
|
||||
|
||||
const cancelBtn = await screen.findByText('common.operation.cancel')
|
||||
await user.click(cancelBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('common.chat.renameConversation')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(mockContextDefaults.handleRenameConversation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle rename success flow', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleRenameConversation = vi.fn()
|
||||
const mockConv = conversation()
|
||||
setup({
|
||||
currentConversationId: 'conv-1',
|
||||
currentConversationItem: mockConv,
|
||||
handleRenameConversation,
|
||||
sidebarCollapseState: true,
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('My Chat'))
|
||||
|
||||
const renameMenuBtn = await screen.findByText('explore.sidebar.action.rename')
|
||||
await user.click(renameMenuBtn)
|
||||
|
||||
expect(await screen.findByText('common.chat.renameConversation'))!.toBeInTheDocument()
|
||||
|
||||
const input = screen.getByDisplayValue('My Chat')
|
||||
await user.clear(input)
|
||||
await user.type(input, 'New Name')
|
||||
|
||||
const saveBtn = await screen.findByText('common.operation.save')
|
||||
await user.click(saveBtn)
|
||||
|
||||
expect(handleRenameConversation).toHaveBeenCalledWith(
|
||||
'conv-1',
|
||||
'New Name',
|
||||
expect.any(Object),
|
||||
)
|
||||
|
||||
const successCallback = handleRenameConversation.mock.calls[0]![2].onSuccess
|
||||
await act(async () => {
|
||||
successCallback()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('common.chat.renameConversation')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle delete flow', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleDeleteConversation = vi.fn()
|
||||
const mockConv = conversation()
|
||||
setup({
|
||||
currentConversationId: 'conv-1',
|
||||
currentConversationItem: mockConv,
|
||||
handleDeleteConversation,
|
||||
sidebarCollapseState: true,
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('My Chat'))
|
||||
|
||||
const deleteMenuBtn = await screen.findByText('explore.sidebar.action.delete')
|
||||
await user.click(deleteMenuBtn)
|
||||
|
||||
expect(handleDeleteConversation).not.toHaveBeenCalled()
|
||||
expect(await screen.findByText('share.chat.deleteConversation.title'))!.toBeInTheDocument()
|
||||
|
||||
const confirmBtn = await screen.findByText('common.operation.confirm')
|
||||
await user.click(confirmBtn)
|
||||
|
||||
expect(handleDeleteConversation).toHaveBeenCalledWith('conv-1', expect.any(Object))
|
||||
|
||||
const successCallback = handleDeleteConversation.mock.calls[0]![1].onSuccess
|
||||
await act(async () => {
|
||||
successCallback()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('share.chat.deleteConversation.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle delete cancellation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const mockConv = conversation()
|
||||
setup({
|
||||
currentConversationId: 'conv-1',
|
||||
currentConversationItem: mockConv,
|
||||
sidebarCollapseState: true,
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('My Chat'))
|
||||
|
||||
const deleteMenuBtn = await screen.findByText('explore.sidebar.action.delete')
|
||||
await user.click(deleteMenuBtn)
|
||||
|
||||
const cancelBtn = await screen.findByText('common.operation.cancel')
|
||||
await user.click(cancelBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('share.chat.deleteConversation.title')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(mockContextDefaults.handleDeleteConversation).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each([false, true])('refreshes the pin action after saving pinned=%s', async (pinned) => {
|
||||
const user = userEvent.setup()
|
||||
const { queryClient, listKey } = renderChatHistory(<Header />, {
|
||||
conversations: pinned ? [] : [conversation],
|
||||
pinnedConversations: pinned ? [conversation] : [],
|
||||
})
|
||||
vi.mocked(fetchConversations).mockImplementation(
|
||||
async (_source, _app, _last, requestedPinned) => ({
|
||||
data: requestedPinned === !pinned ? [conversation] : [],
|
||||
has_more: false,
|
||||
limit: 100,
|
||||
}),
|
||||
)
|
||||
await operate(user, pinned ? 'unpin' : 'pin')
|
||||
await waitFor(() =>
|
||||
expect(queryClient.getQueryData(listKey(!pinned))).toMatchObject({ data: [conversation] }),
|
||||
)
|
||||
await user.click(await screen.findByRole('button', { name: 'My Chat' }))
|
||||
expect(
|
||||
await screen.findByRole('menuitem', {
|
||||
name: `explore.sidebar.action.${pinned ? 'pin' : 'unpin'}`,
|
||||
}),
|
||||
).toBeVisible()
|
||||
expect(pinned ? unpinConversation : pinConversation).toHaveBeenCalledExactlyOnceWith(
|
||||
AppSourceType.webApp,
|
||||
'app-1',
|
||||
'conversation-1',
|
||||
)
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should not render inputs form dropdown if inputsForms is empty', () => {
|
||||
const mockConv = conversation()
|
||||
setup({
|
||||
currentConversationId: 'conv-1',
|
||||
currentConversationItem: mockConv,
|
||||
inputsForms: [],
|
||||
})
|
||||
it('saves the edited conversation name and closes the dialog through the real owner', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderChatHistory(<Header />)
|
||||
await operate(user, 'rename')
|
||||
const input = await screen.findByRole('textbox', { name: 'common.chat.conversationName' })
|
||||
await user.clear(input)
|
||||
await user.type(input, 'Renamed conversation')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(await screen.findByRole('button', { name: 'Renamed conversation' })).toBeVisible()
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(renameConversation).toHaveBeenCalledExactlyOnceWith(
|
||||
AppSourceType.webApp,
|
||||
'app-1',
|
||||
'conversation-1',
|
||||
'Renamed conversation',
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['rename', 'delete'])(
|
||||
'cancels %s without changing the selected conversation',
|
||||
async (action) => {
|
||||
const user = userEvent.setup()
|
||||
renderChatHistory(<Header />)
|
||||
await operate(user, action)
|
||||
if (action === 'rename') await user.type(await screen.findByRole('textbox'), ' unsaved')
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.cancel' }))
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'share.chat.viewChatSettings' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
screen.queryByRole(action === 'rename' ? 'dialog' : 'alertdialog'),
|
||||
).not.toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'My Chat' })).toBeVisible()
|
||||
expect(renameConversation).not.toHaveBeenCalled()
|
||||
expect(delConversation).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('should render system title if conversation id is missing', () => {
|
||||
setup({ currentConversationId: '', sidebarCollapseState: true })
|
||||
const titleEl = screen.getByText('Test App')
|
||||
expect(titleEl).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app icon from URL when icon_url is provided', () => {
|
||||
vi.spyOn(HTMLImageElement.prototype, 'complete', 'get').mockReturnValue(true)
|
||||
vi.spyOn(HTMLImageElement.prototype, 'naturalWidth', 'get').mockReturnValue(32)
|
||||
const { container } = setup({
|
||||
appData: {
|
||||
...mockAppData,
|
||||
site: {
|
||||
...mockAppData.site,
|
||||
icon_type: 'image',
|
||||
icon_url: 'https://example.com/icon.png',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'https://example.com/icon.png')
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle New Chat button state when currentConversationId is present but isResponding is true', () => {
|
||||
setup({
|
||||
isResponding: true,
|
||||
sidebarCollapseState: true,
|
||||
currentConversationId: 'conv-1',
|
||||
})
|
||||
|
||||
const newChatBtn = screen.getByRole('button', { name: 'share.chat.newChatTip' })
|
||||
expect(newChatBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should handle New Chat button state when currentConversationId is missing and isResponding is false', () => {
|
||||
setup({
|
||||
isResponding: false,
|
||||
sidebarCollapseState: true,
|
||||
currentConversationId: '',
|
||||
})
|
||||
|
||||
const newChatBtn = screen.getByRole('button', { name: 'share.chat.newChatTip' })
|
||||
expect(newChatBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should not render operation menu if conversation id is missing', () => {
|
||||
setup({ currentConversationId: '', sidebarCollapseState: true })
|
||||
expect(screen.queryByText('My Chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render operation menu if sidebar is NOT collapsed', () => {
|
||||
const mockConv = conversation()
|
||||
setup({
|
||||
currentConversationId: 'conv-1',
|
||||
currentConversationItem: mockConv,
|
||||
sidebarCollapseState: false,
|
||||
})
|
||||
expect(screen.queryByText('My Chat')).not.toBeInTheDocument()
|
||||
})
|
||||
it('deletes the selected conversation, refreshes the list and returns to New Chat', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { queryClient, listKey } = renderChatHistory(<Header />)
|
||||
await operate(user, 'delete')
|
||||
expect(delConversation).not.toHaveBeenCalled()
|
||||
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
|
||||
expect(await screen.findByText('Test Chat')).toBeVisible()
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
|
||||
await waitFor(() => expect(queryClient.getQueryData(listKey(false))).toMatchObject({ data: [] }))
|
||||
expect(delConversation).toHaveBeenCalledExactlyOnceWith(
|
||||
AppSourceType.webApp,
|
||||
'app-1',
|
||||
'conversation-1',
|
||||
)
|
||||
expect(JSON.parse(sessionStorage.getItem(TAB_CONVERSATION_ID_INFO)!)).toEqual({
|
||||
'app-1': { 'user-1': '' },
|
||||
})
|
||||
})
|
||||
|
||||
it('opens chat settings with the configured inputs and closes with Escape', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderChatHistory(<Header />, {
|
||||
appParams: createChatConfigFixture({
|
||||
user_input_form: [
|
||||
{ 'text-input': { label: 'Topic', variable: 'topic', required: false, default: 'Travel' } },
|
||||
],
|
||||
}),
|
||||
})
|
||||
await user.click(await screen.findByRole('button', { name: 'share.chat.viewChatSettings' }))
|
||||
expect(screen.getByRole('textbox', { name: 'Topic' })).toHaveValue('Travel')
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('textbox', { name: 'Topic' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ currentConversationId: '', responding: false },
|
||||
{ currentConversationId: 'conversation-1', responding: true },
|
||||
])(
|
||||
'disables New Chat for selection=$currentConversationId and responding=$responding',
|
||||
({ currentConversationId, responding }) => {
|
||||
renderChatHistory(<Header />, {
|
||||
currentConversationId,
|
||||
ownerProps: { isResponding: responding },
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'share.chat.newChatTip' })).toBeDisabled()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'share.chat.viewChatSettings' }),
|
||||
).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('hides conversation operations while the sidebar is expanded', () => {
|
||||
renderChatHistory(<Header />, { sidebarCollapsed: false })
|
||||
expect(screen.queryByRole('button', { name: 'My Chat' })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -12,19 +12,25 @@ import {
|
||||
STEP_BY_STEP_TOUR_TARGETS,
|
||||
} from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { createDeferred } from '@/test/deferred'
|
||||
import { createPluginEntity } from '@/test/fixtures/plugin'
|
||||
import { PluginCategoryEnum } from '../../types'
|
||||
import { PluginPageContext } from '../context'
|
||||
import PluginsPanel from '../plugins-panel'
|
||||
|
||||
const requests = vi.hoisted(() => ({ list: vi.fn(), invalidate: vi.fn(), nextPage: vi.fn() }))
|
||||
// Service pagination and marketplace enrichment are tested by their own owners.
|
||||
// This suite exercises list filtering, selection and pagination controls with real cards.
|
||||
vi.mock('@/service/use-plugins', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/service/use-plugins')>()),
|
||||
useInstalledPluginList: (...args: unknown[]) => requests.list(...args),
|
||||
useInvalidateInstalledPluginList: () => requests.invalidate,
|
||||
useRemoveFilteredInstalledPluginPageOnUnmount: () => {},
|
||||
}))
|
||||
const requests = vi.hoisted(() => ({ list: vi.fn() }))
|
||||
vi.mock('@/service/console', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/console')>()
|
||||
const { withConsoleResponses } = await import('@/test/console/service-responses')
|
||||
return {
|
||||
...actual,
|
||||
consoleQuery: withConsoleResponses(actual.consoleQuery, {
|
||||
'workspaces.current.plugin.list.get': requests.list,
|
||||
'workspaces.current.plugin.byCategory.list.get': requests.list,
|
||||
}),
|
||||
}
|
||||
})
|
||||
// Marketplace version enrichment is independent of installed-list pagination and filtering.
|
||||
vi.mock('../../hooks', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../hooks')>()),
|
||||
usePluginsWithLatestVersion: (plugins: PluginDetail[] = []) => plugins,
|
||||
@@ -97,36 +103,23 @@ const createPlugin = (
|
||||
label: string,
|
||||
tags: string[] = [],
|
||||
category: PluginCategoryEnum = PluginCategoryEnum.tool,
|
||||
): PluginDetail =>
|
||||
({
|
||||
): ReturnType<typeof createPluginEntity> => {
|
||||
const base = createPluginEntity()
|
||||
return {
|
||||
...base,
|
||||
id: pluginId,
|
||||
created_at: '2024-01-01',
|
||||
updated_at: '2024-01-02',
|
||||
name: label,
|
||||
plugin_id: pluginId,
|
||||
plugin_unique_identifier: `${pluginId}-uid`,
|
||||
declaration: {
|
||||
category,
|
||||
author: 'test-author',
|
||||
icon: 'https://example.com/plugin.png',
|
||||
meta: { version: '1.0.0', minimum_dify_version: '0.0.0' },
|
||||
...base.declaration,
|
||||
name: pluginId,
|
||||
category,
|
||||
label: { en_US: label },
|
||||
description: { en_US: `${label} description` },
|
||||
tags,
|
||||
} as PluginDetail['declaration'],
|
||||
installation_id: `${pluginId}-install`,
|
||||
tenant_id: 'tenant-1',
|
||||
endpoints_setups: 0,
|
||||
endpoints_active: 0,
|
||||
version: '1.0.0',
|
||||
latest_version: '1.0.0',
|
||||
latest_unique_identifier: `${pluginId}-uid`,
|
||||
source: 'marketplace' as PluginDetail['source'],
|
||||
status: 'active',
|
||||
deprecated_reason: '',
|
||||
alternative_plugin_id: '',
|
||||
}) as PluginDetail
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const createBuiltinTool = (id: string, label: string, labels: string[] = []): Collection => ({
|
||||
id,
|
||||
@@ -142,11 +135,8 @@ const createBuiltinTool = (id: string, label: string, labels: string[] = []): Co
|
||||
labels,
|
||||
})
|
||||
|
||||
let plugins: PluginDetail[]
|
||||
let plugins: ReturnType<typeof createPluginEntity>[]
|
||||
let builtinTools: Collection[]
|
||||
let fetching: boolean
|
||||
let loading: boolean
|
||||
let lastPage: boolean
|
||||
const observers: IntersectionObserverCallback[] = []
|
||||
const initialFilters: FilterState = { categories: [], tags: [], searchQuery: '' }
|
||||
function Harness({
|
||||
@@ -177,34 +167,33 @@ function Harness({
|
||||
</PluginPageContext.Provider>
|
||||
)
|
||||
}
|
||||
const renderPanel = (
|
||||
const renderPanel = async (
|
||||
props: ComponentProps<typeof PluginsPanel> = {},
|
||||
filters = initialFilters,
|
||||
marketplace = true,
|
||||
selection?: PluginPageSelection,
|
||||
) =>
|
||||
renderWithConsoleQuery(
|
||||
) => {
|
||||
const result = renderWithConsoleQuery(
|
||||
<Harness filters={filters} selection={selection}>
|
||||
<PluginsPanel {...props} />
|
||||
</Harness>,
|
||||
{ systemFeatures: { enable_marketplace: marketplace } },
|
||||
)
|
||||
await waitFor(() => expect(result.queryClient.isFetching()).toBe(0))
|
||||
return result
|
||||
}
|
||||
|
||||
const pluginButton = (name: string) => screen.getByRole('button', { name })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
plugins = []
|
||||
builtinTools = []
|
||||
fetching = false
|
||||
loading = false
|
||||
lastPage = true
|
||||
observers.length = 0
|
||||
requests.list.mockImplementation(() => ({
|
||||
data: { plugins, builtin_tools: builtinTools },
|
||||
isLoading: loading,
|
||||
isFetching: fetching,
|
||||
isLastPage: lastPage,
|
||||
loadNextPage: requests.nextPage,
|
||||
requests.list.mockReset().mockImplementation(async () => ({
|
||||
plugins,
|
||||
builtin_tools: builtinTools,
|
||||
total: plugins.length,
|
||||
has_more: false,
|
||||
}))
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
@@ -223,19 +212,25 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('Plugin list panel', () => {
|
||||
it('shows loading before exposing installed results', () => {
|
||||
loading = true
|
||||
renderPanel()
|
||||
it('shows loading before exposing installed results', async () => {
|
||||
const request = createDeferred<{
|
||||
plugins: ReturnType<typeof createPluginEntity>[]
|
||||
total: number
|
||||
}>()
|
||||
requests.list.mockReturnValue(request.promise)
|
||||
const rendered = renderPanel()
|
||||
expect(screen.getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('plugin.list.noInstalled')).not.toBeInTheDocument()
|
||||
await act(async () => request.resolve({ plugins: [], total: 0 }))
|
||||
await rendered
|
||||
})
|
||||
|
||||
it('locks the fixed category while retaining tag filters and toolbar actions', () => {
|
||||
it('locks the fixed category while retaining tag filters and toolbar actions', async () => {
|
||||
plugins = [
|
||||
createPlugin('trigger', 'Trigger Plugin', [], PluginCategoryEnum.trigger),
|
||||
createPlugin('tool', 'Tool Plugin'),
|
||||
]
|
||||
renderPanel({
|
||||
await renderPanel({
|
||||
fixedCategory: PluginCategoryEnum.trigger,
|
||||
toolbarAction: <button>Update settings</button>,
|
||||
})
|
||||
@@ -244,13 +239,15 @@ describe('Plugin list panel', () => {
|
||||
expect(screen.queryByRole('button', { name: 'plugin.allCategories' })).not.toBeInTheDocument()
|
||||
expect(pluginButton('common.tag.tags')).toBeInTheDocument()
|
||||
expect(pluginButton('Update settings')).toBeInTheDocument()
|
||||
expect(requests.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ category: PluginCategoryEnum.trigger, pageSize: 30 }),
|
||||
)
|
||||
expect(requests.list).toHaveBeenCalledWith({
|
||||
params: { category: PluginCategoryEnum.trigger },
|
||||
query: { page: 1, page_size: 30, language: 'en_US' },
|
||||
})
|
||||
})
|
||||
|
||||
it('applies tag filters to both installed tools and the marketplace handoff', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
plugins = [
|
||||
createPlugin('search-plugin', 'Search Plugin', ['search']),
|
||||
createPlugin('rag-plugin', 'Rag Plugin', ['rag']),
|
||||
@@ -259,19 +256,18 @@ describe('Plugin list panel', () => {
|
||||
createBuiltinTool('search-builtin', 'Search Builtin', ['search']),
|
||||
createBuiltinTool('rag-builtin', 'Rag Builtin', ['rag']),
|
||||
]
|
||||
renderPanel({ fixedCategory: PluginCategoryEnum.tool })
|
||||
await renderPanel({ fixedCategory: PluginCategoryEnum.tool })
|
||||
await user.click(pluginButton('common.tag.tags'))
|
||||
await user.click(await screen.findByRole('checkbox', { name: 'pluginTags.tags.search' }))
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: 'Rag Plugin' })).not.toBeInTheDocument(),
|
||||
)
|
||||
expect(pluginButton('Search Plugin')).toBeInTheDocument()
|
||||
await act(async () => vi.advanceTimersByTimeAsync(600))
|
||||
expect(await screen.findByRole('button', { name: 'Search Plugin' })).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'Rag Plugin' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Search Builtin/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Rag Builtin/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'Tool marketplace' })).toHaveTextContent('search')
|
||||
expect(requests.list).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ filters: expect.objectContaining({ tags: ['search'] }) }),
|
||||
expect.objectContaining({ query: expect.objectContaining({ tags: ['search'] }) }),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -279,7 +275,7 @@ describe('Plugin list panel', () => {
|
||||
const user = userEvent.setup()
|
||||
plugins = [createPlugin('tool-plugin', 'Tool Plugin')]
|
||||
builtinTools = [createBuiltinTool('builtin-tool', 'Builtin Tool')]
|
||||
renderPanel({ fixedCategory: PluginCategoryEnum.tool })
|
||||
await renderPanel({ fixedCategory: PluginCategoryEnum.tool })
|
||||
const builtin = screen.getByRole('button', { name: /Builtin Tool/ })
|
||||
await user.click(builtin)
|
||||
expect(builtin).toHaveAttribute('aria-current', 'true')
|
||||
@@ -290,8 +286,10 @@ describe('Plugin list panel', () => {
|
||||
expect(screen.getByRole('region', { name: 'Plugin details' })).toHaveTextContent('tool-plugin')
|
||||
expect(screen.queryByRole('region', { name: 'Builtin tool details' })).not.toBeInTheDocument()
|
||||
expect(builtin).not.toHaveAttribute('aria-current')
|
||||
plugins = [createPlugin('tool-plugin', 'Updated Plugin')]
|
||||
await user.click(pluginButton('Refresh plugin details'))
|
||||
expect(requests.invalidate).toHaveBeenCalledWith(PluginCategoryEnum.tool)
|
||||
expect(await screen.findByRole('button', { name: 'Updated Plugin' })).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'Tool Plugin' })).not.toBeInTheDocument()
|
||||
await user.click(pluginButton('Close plugin details'))
|
||||
expect(screen.queryByRole('region', { name: 'Plugin details' })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -317,8 +315,8 @@ describe('Plugin list panel', () => {
|
||||
},
|
||||
])(
|
||||
'keeps the $category catalog browsable without install permission and hides unsupported filters',
|
||||
({ category, empty, target, supportsTags }) => {
|
||||
renderPanel({ fixedCategory: category, canInstall: false })
|
||||
async ({ category, empty, target, supportsTags }) => {
|
||||
await renderPanel({ fixedCategory: category, canInstall: false })
|
||||
expect(screen.getByText(`plugin.list.${empty}`)).toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: `${category} marketplace` })).toBeInTheDocument()
|
||||
expect(
|
||||
@@ -334,8 +332,8 @@ describe('Plugin list panel', () => {
|
||||
|
||||
it.each([PluginCategoryEnum.trigger, PluginCategoryEnum.agent, PluginCategoryEnum.extension])(
|
||||
'omits %s marketplace navigation when disabled',
|
||||
(category) => {
|
||||
renderPanel({ fixedCategory: category }, initialFilters, false)
|
||||
async (category) => {
|
||||
await renderPanel({ fixedCategory: category }, initialFilters, false)
|
||||
expect(
|
||||
screen.queryByRole('region', { name: `${category} marketplace` }),
|
||||
).not.toBeInTheDocument()
|
||||
@@ -347,16 +345,16 @@ describe('Plugin list panel', () => {
|
||||
|
||||
it.each([PluginCategoryEnum.agent, PluginCategoryEnum.extension])(
|
||||
'ignores hidden tag filters for %s in both local results and service requests',
|
||||
(category) => {
|
||||
async (category) => {
|
||||
plugins = [
|
||||
createPlugin('rag', 'Rag Plugin', ['rag'], category),
|
||||
createPlugin('search', 'Search Plugin', ['search'], category),
|
||||
]
|
||||
renderPanel({ fixedCategory: category }, { ...initialFilters, tags: ['search'] })
|
||||
await renderPanel({ fixedCategory: category }, { ...initialFilters, tags: ['search'] })
|
||||
expect(pluginButton('Rag Plugin')).toBeInTheDocument()
|
||||
expect(pluginButton('Search Plugin')).toBeInTheDocument()
|
||||
expect(requests.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filters: expect.objectContaining({ tags: [] }) }),
|
||||
expect.objectContaining({ query: { page: 1, page_size: 30, language: 'en_US' } }),
|
||||
)
|
||||
expect(screen.getByRole('region', { name: `${category} marketplace` })).not.toHaveTextContent(
|
||||
'search',
|
||||
@@ -375,33 +373,48 @@ describe('Plugin list panel', () => {
|
||||
createBuiltinTool('alpha-builtin', 'Alpha Builtin'),
|
||||
createBuiltinTool('beta-builtin', 'Beta Builtin'),
|
||||
]
|
||||
renderPanel({ fixedCategory: PluginCategoryEnum.tool })
|
||||
await renderPanel({ fixedCategory: PluginCategoryEnum.tool })
|
||||
await user.type(screen.getByRole('searchbox', { name: 'plugin.search' }), 'beta')
|
||||
expect(pluginButton('Alpha Plugin')).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
await act(async () => vi.advanceTimersByTimeAsync(600))
|
||||
expect(await screen.findByRole('button', { name: 'Beta Plugin' })).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'Alpha Plugin' })).not.toBeInTheDocument()
|
||||
expect(pluginButton('Beta Plugin')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Beta Builtin/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Alpha Builtin/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('region', { name: 'Tool marketplace' })).toHaveTextContent('beta')
|
||||
})
|
||||
|
||||
it('offers manual pagination on the standalone plugin page', async () => {
|
||||
it('offers manual pagination on the standalone plugin page and appends the returned cards', async () => {
|
||||
const user = userEvent.setup()
|
||||
plugins = [createPlugin('tool', 'Tool Plugin')]
|
||||
lastPage = false
|
||||
renderPanel()
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) =>
|
||||
createPlugin(`tool-${index}`, `Tool ${index}`),
|
||||
)
|
||||
requests.list
|
||||
.mockResolvedValueOnce({ plugins: firstPage, total: 101 })
|
||||
.mockResolvedValueOnce({ plugins: [createPlugin('last-tool', 'Last Tool')], total: 101 })
|
||||
await renderPanel()
|
||||
await user.click(pluginButton('workflow.common.loadMore'))
|
||||
expect(requests.nextPage).toHaveBeenCalledOnce()
|
||||
expect(observers).toHaveLength(0)
|
||||
expect(await screen.findByRole('button', { name: 'Last Tool' })).toBeVisible()
|
||||
expect(pluginButton('Tool 0')).toBeVisible()
|
||||
expect(pluginButton('Tool 99')).toBeVisible()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'workflow.common.loadMore' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(requests.list).toHaveBeenLastCalledWith({ query: { page: 2, page_size: 100 } })
|
||||
})
|
||||
|
||||
it('loads another integration page once per intersection and announces pagination without replacing the status', () => {
|
||||
plugins = [createPlugin('tool', 'Tool Plugin')]
|
||||
lastPage = false
|
||||
const { rerender } = renderPanel({ fixedCategory: PluginCategoryEnum.tool })
|
||||
it('loads another integration page once per intersection and announces pagination without replacing the status', async () => {
|
||||
const nextPage = createDeferred<{
|
||||
plugins: ReturnType<typeof createPluginEntity>[]
|
||||
has_more: boolean
|
||||
}>()
|
||||
const firstPage = Array.from({ length: 30 }, (_, index) =>
|
||||
createPlugin(`tool-${index}`, `Tool ${index}`),
|
||||
)
|
||||
requests.list
|
||||
.mockResolvedValueOnce({ plugins: firstPage, has_more: true })
|
||||
.mockReturnValueOnce(nextPage.promise)
|
||||
await renderPanel({ fixedCategory: PluginCategoryEnum.tool })
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveTextContent('plugin.marketplace.pluginsResult')
|
||||
const intersect = () =>
|
||||
@@ -413,34 +426,29 @@ describe('Plugin list panel', () => {
|
||||
intersect()
|
||||
intersect()
|
||||
})
|
||||
expect(requests.nextPage).toHaveBeenCalledOnce()
|
||||
fetching = true
|
||||
rerender(
|
||||
<Harness>
|
||||
<PluginsPanel fixedCategory={PluginCategoryEnum.tool} />
|
||||
</Harness>,
|
||||
)
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('common.loading'))
|
||||
expect(screen.getByRole('status')).toBe(status)
|
||||
expect(status).toHaveTextContent('common.loading')
|
||||
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-live')
|
||||
expect(observers).toHaveLength(1)
|
||||
fetching = false
|
||||
lastPage = true
|
||||
rerender(
|
||||
<Harness>
|
||||
<PluginsPanel fixedCategory={PluginCategoryEnum.tool} />
|
||||
</Harness>,
|
||||
expect(requests.list).toHaveBeenCalledTimes(2)
|
||||
expect(requests.list).toHaveBeenLastCalledWith({
|
||||
params: { category: PluginCategoryEnum.tool },
|
||||
query: { page: 2, page_size: 30, language: 'en_US' },
|
||||
})
|
||||
await act(async () =>
|
||||
nextPage.resolve({ plugins: [createPlugin('last-tool', 'Last Tool')], has_more: false }),
|
||||
)
|
||||
expect(await screen.findByRole('button', { name: 'Last Tool' })).toBeVisible()
|
||||
expect(pluginButton('Tool 0')).toBeVisible()
|
||||
expect(pluginButton('Tool 29')).toBeVisible()
|
||||
expect(screen.getByRole('status')).toBe(status)
|
||||
expect(status).toHaveTextContent('plugin.marketplace.pluginsResult')
|
||||
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
|
||||
expect(observers).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves the selected plugin details when filtering hides its card', async () => {
|
||||
const user = userEvent.setup()
|
||||
plugins = [createPlugin('tool-plugin', 'Tool Plugin')]
|
||||
renderPanel({}, { ...initialFilters, searchQuery: 'missing' }, true, {
|
||||
await renderPanel({}, { ...initialFilters, searchQuery: 'missing' }, true, {
|
||||
type: 'plugin',
|
||||
id: 'tool-plugin',
|
||||
})
|
||||
@@ -451,9 +459,9 @@ describe('Plugin list panel', () => {
|
||||
expect(screen.queryByRole('region', { name: 'Plugin details' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps category marketplace search available without presenting a no-installation prompt for unmatched searches', () => {
|
||||
it('keeps category marketplace search available without presenting a no-installation prompt for unmatched searches', async () => {
|
||||
plugins = [createPlugin('trigger', 'Trigger Plugin', [], PluginCategoryEnum.trigger)]
|
||||
renderPanel(
|
||||
await renderPanel(
|
||||
{ fixedCategory: PluginCategoryEnum.trigger },
|
||||
{ ...initialFilters, searchQuery: 'missing' },
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user