dify/web/app/components/goto-anything/__tests__/index.spec.tsx
Wu Tianwei 33edf97f81
feat: RBAC (#37107)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: fatelei <fatelei@gmail.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
Co-authored-by: Charles Yao <chongbinyao33@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: Jingyi <jingyi.qi@dify.ai>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
Co-authored-by: gigglewang <gigglewang@dify.ai>
Co-authored-by: chariri <w@chariri.moe>
Co-authored-by: Evan <2869018789@qq.com>
Co-authored-by: zyssyz123 <916125788@qq.com>
2026-06-18 16:35:29 +00:00

659 lines
19 KiB
TypeScript

import type { ReactNode } from 'react'
import type { ActionItem, SearchResult } from '../actions/types'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createStore, Provider } from 'jotai'
import * as React from 'react'
import { GotoAnything } from '../index'
type TestSearchResult = Omit<SearchResult, 'icon' | 'data'> & {
icon?: ReactNode
data?: Record<string, unknown>
}
const routerPush = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: routerPush,
}),
usePathname: () => '/',
}))
type KeyPressEvent = {
preventDefault: () => void
target?: EventTarget
}
type HotkeyRegistration = {
handler: (event: KeyPressEvent) => void
options?: { enabled?: boolean }
}
const hotkeyHandlers: Record<string, HotkeyRegistration> = {}
vi.mock('ahooks', () => ({
useDebounce: <T,>(value: T) => value,
}))
vi.mock('@tanstack/react-hotkeys', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-hotkeys')>()
return {
...actual,
useHotkey: (
hotkey: string,
handler: (event: KeyPressEvent) => void,
options?: HotkeyRegistration['options'],
) => {
hotkeyHandlers[hotkey] = { handler, options }
},
}
})
const HOTKEY_ALIAS: Record<string, string> = {
'ctrl.k': 'Mod+K',
}
const triggerKeyPress = (combo: string) => {
const hotkey = HOTKEY_ALIAS[combo] ?? combo
const registration = hotkeyHandlers[hotkey]
if (registration && registration.options?.enabled !== false) {
act(() => {
registration.handler({ preventDefault: vi.fn(), target: document.body })
})
}
}
let mockQueryResult = { data: [] as TestSearchResult[], isLoading: false, isError: false, error: null as Error | null }
vi.mock('@tanstack/react-query', () => ({
useQuery: () => mockQueryResult,
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
vi.mock('@/app/components/plugins/install-plugin/hooks/use-workspace-plugin-install-permission', () => ({
default: () => ({
canInstallPlugin: true,
currentDifyVersion: '1.0.0',
}),
}))
const contextValue = { isWorkflowPage: false, isRagPipelinePage: false }
vi.mock('../context', () => ({
useGotoAnythingContext: () => contextValue,
GotoAnythingProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
const createActionItem = (key: ActionItem['key'], shortcut: string): ActionItem => ({
key,
shortcut,
title: `${key} title`,
description: `${key} desc`,
action: vi.fn(),
search: vi.fn(),
})
const actionsMock = {
slash: createActionItem('/', '/'),
app: createActionItem('@app', '@app'),
plugin: createActionItem('@plugin', '@plugin'),
}
const createActionsMock = vi.fn(() => actionsMock)
const matchActionMock = vi.fn(() => undefined)
const searchAnythingMock = vi.fn(async () => mockQueryResult.data)
vi.mock('../actions', () => ({
createActions: () => createActionsMock(),
matchAction: () => matchActionMock(),
searchAnything: () => searchAnythingMock(),
}))
vi.mock('../actions/commands/slash-provider', () => ({
SlashCommandProvider: () => null,
}))
type MockSlashCommand = {
mode: string
execute?: () => void
isAvailable?: () => boolean
} | null
let mockFindCommand: MockSlashCommand = null
vi.mock('../actions/commands/registry', () => ({
slashCommandRegistry: {
findCommand: () => mockFindCommand,
getAvailableCommands: () => [],
getAllCommands: () => [],
},
}))
vi.mock('@/app/components/workflow/utils/node-navigation', () => ({
selectWorkflowNode: vi.fn(),
}))
vi.mock('../../plugins/install-plugin/install-from-marketplace', () => ({
default: (props: { manifest?: { name?: string }, onClose: () => void, onSuccess: () => void }) => (
<div data-testid="install-modal">
<span>{props.manifest?.name}</span>
<button onClick={props.onClose} data-testid="close-install">close</button>
<button onClick={props.onSuccess} data-testid="success-install">success</button>
</div>
),
}))
const renderGotoAnything = (ui: React.ReactElement) => {
const store = createStore()
return render(
<Provider store={store}>
{ui}
</Provider>,
)
}
describe('GotoAnything', () => {
beforeEach(() => {
routerPush.mockClear()
Object.keys(hotkeyHandlers).forEach(key => delete hotkeyHandlers[key])
mockQueryResult = { data: [], isLoading: false, isError: false, error: null }
matchActionMock.mockReset()
searchAnythingMock.mockClear()
mockFindCommand = null
})
describe('modal behavior', () => {
it('should open modal via Ctrl+K shortcut', async () => {
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
})
it('should close modal via ESC key', async () => {
const user = userEvent.setup()
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
})
})
it('should toggle modal when pressing Ctrl+K twice', async () => {
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
})
})
it('should call onHide when modal closes', async () => {
const user = userEvent.setup()
const onHide = vi.fn()
renderGotoAnything(<GotoAnything onHide={onHide} />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
await user.keyboard('{Escape}')
await waitFor(() => {
expect(onHide).toHaveBeenCalled()
})
})
it('should reset search query when modal opens', async () => {
const user = userEvent.setup()
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'test')
await user.keyboard('{Escape}')
await waitFor(() => {
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
})
triggerKeyPress('ctrl.k')
await waitFor(() => {
const newInput = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
expect(newInput).toHaveValue('')
})
})
})
describe('search functionality', () => {
it('should navigate to selected result', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [{
id: 'app-1',
type: 'app',
title: 'Sample App',
description: 'desc',
path: '/apps/1',
icon: <div data-testid="icon">🧩</div>,
data: {},
}],
isLoading: false,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'app')
const result = await screen.findByText('Sample App')
await user.click(result)
expect(routerPush).toHaveBeenCalledWith('/apps/1')
})
it('should clear selection when typing without prefix', async () => {
const user = userEvent.setup()
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'test query')
expect(input).toHaveValue('test query')
})
})
describe('empty states', () => {
it('should show loading state', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [],
isLoading: true,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'search')
const searchingTexts = screen.getAllByText('app.gotoAnything.searching')
expect(searchingTexts.length).toBeGreaterThanOrEqual(1)
})
it('should show error state', async () => {
const user = userEvent.setup()
const testError = new Error('Search failed')
mockQueryResult = {
data: [],
isLoading: false,
isError: true,
error: testError,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'search')
expect(screen.getByText('app.gotoAnything.searchFailed')).toBeInTheDocument()
})
it('should show default state when no query', async () => {
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
expect(screen.getByText('app.gotoAnything.searchTitle')).toBeInTheDocument()
})
it('should show no results state when search returns empty', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [],
isLoading: false,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'nonexistent')
expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument()
})
})
describe('plugin installation', () => {
it('should open plugin installer when selecting plugin result', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
}],
isLoading: false,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'plugin')
const pluginItem = await screen.findByText('Plugin Item')
await user.click(pluginItem)
expect(await screen.findByTestId('install-modal')).toHaveTextContent('Plugin Item')
})
it('should close plugin installer via close button', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
}],
isLoading: false,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'plugin')
const pluginItem = await screen.findByText('Plugin Item')
await user.click(pluginItem)
const closeBtn = await screen.findByTestId('close-install')
await user.click(closeBtn)
await waitFor(() => {
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
})
it('should close plugin installer on success', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [{
id: 'plugin-1',
type: 'plugin',
title: 'Plugin Item',
description: 'desc',
path: '',
icon: <div />,
data: {
name: 'Plugin Item',
latest_package_identifier: 'pkg',
},
}],
isLoading: false,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'plugin')
const pluginItem = await screen.findByText('Plugin Item')
await user.click(pluginItem)
const successBtn = await screen.findByTestId('success-install')
await user.click(successBtn)
await waitFor(() => {
expect(screen.queryByTestId('install-modal')).not.toBeInTheDocument()
})
})
})
describe('slash command handling', () => {
it('should execute direct slash command on Enter', async () => {
const user = userEvent.setup()
const executeMock = vi.fn()
mockFindCommand = {
mode: 'direct',
execute: executeMock,
isAvailable: () => true,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, '/theme')
await user.keyboard('{Enter}')
expect(executeMock).toHaveBeenCalled()
})
it('should NOT execute unavailable slash command', async () => {
const user = userEvent.setup()
const executeMock = vi.fn()
mockFindCommand = {
mode: 'direct',
execute: executeMock,
isAvailable: () => false,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, '/theme')
await user.keyboard('{Enter}')
expect(executeMock).not.toHaveBeenCalled()
})
it('should NOT execute non-direct mode slash command on Enter', async () => {
const user = userEvent.setup()
const executeMock = vi.fn()
mockFindCommand = {
mode: 'submenu',
execute: executeMock,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, '/language')
await user.keyboard('{Enter}')
expect(executeMock).not.toHaveBeenCalled()
})
it('should close modal after executing direct slash command', async () => {
const user = userEvent.setup()
mockFindCommand = {
mode: 'direct',
execute: vi.fn(),
isAvailable: () => true,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, '/theme')
await user.keyboard('{Enter}')
await waitFor(() => {
expect(screen.queryByPlaceholderText('app.gotoAnything.searchPlaceholder')).not.toBeInTheDocument()
})
})
})
describe('result navigation', () => {
it('should handle knowledge result navigation', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [{
id: 'kb-1',
type: 'knowledge',
title: 'Knowledge Base',
description: 'desc',
path: '/datasets/kb-1',
icon: <div />,
data: {},
}],
isLoading: false,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'knowledge')
const result = await screen.findByText('Knowledge Base')
await user.click(result)
expect(routerPush).toHaveBeenCalledWith('/datasets/kb-1')
})
it('should NOT navigate when result has no path', async () => {
const user = userEvent.setup()
mockQueryResult = {
data: [{
id: 'item-1',
type: 'app',
title: 'No Path Item',
description: 'desc',
path: '',
icon: <div />,
data: {},
}],
isLoading: false,
isError: false,
error: null,
}
renderGotoAnything(<GotoAnything />)
triggerKeyPress('ctrl.k')
await waitFor(() => {
expect(screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')).toBeInTheDocument()
})
const input = screen.getByPlaceholderText('app.gotoAnything.searchPlaceholder')
await user.type(input, 'no path')
const result = await screen.findByText('No Path Item')
await user.click(result)
expect(routerPush).not.toHaveBeenCalled()
})
})
})