test(web): replace chat and plugin list owner doubles (#44725)

This commit is contained in:
Stephen Zhou authored and GitHub committed 2026-10-25 05:00:48 +00:00
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,
}
}
@@ -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' },
)