diff --git a/web/app/components/goto-anything/command-selector.spec.tsx b/web/app/components/goto-anything/command-selector.spec.tsx new file mode 100644 index 0000000000..ab8b7f6ad3 --- /dev/null +++ b/web/app/components/goto-anything/command-selector.spec.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Command } from 'cmdk' +import CommandSelector from './command-selector' +import type { ActionItem } from './actions/types' + +jest.mock('next/navigation', () => ({ + usePathname: () => '/app', +})) + +const slashCommandsMock = [{ + name: 'zen', + description: 'Zen mode', + mode: 'direct', + isAvailable: () => true, +}] + +jest.mock('./actions/commands/registry', () => ({ + slashCommandRegistry: { + getAvailableCommands: () => slashCommandsMock, + }, +})) + +const createActions = (): Record => ({ + app: { + key: '@app', + shortcut: '@app', + title: 'Apps', + search: jest.fn(), + description: '', + } as ActionItem, + plugin: { + key: '@plugin', + shortcut: '@plugin', + title: 'Plugins', + search: jest.fn(), + description: '', + } as ActionItem, +}) + +describe('CommandSelector', () => { + test('should list contextual search actions and notify selection', async () => { + const actions = createActions() + const onSelect = jest.fn() + + render( + + + , + ) + + const actionButton = screen.getByText('app.gotoAnything.actions.searchApplicationsDesc') + await userEvent.click(actionButton) + + expect(onSelect).toHaveBeenCalledWith('@app') + }) + + test('should render slash commands when query starts with slash', async () => { + const actions = createActions() + const onSelect = jest.fn() + + render( + + + , + ) + + const slashItem = await screen.findByText('app.gotoAnything.actions.zenDesc') + await userEvent.click(slashItem) + + expect(onSelect).toHaveBeenCalledWith('/zen') + }) +}) diff --git a/web/app/components/goto-anything/context.spec.tsx b/web/app/components/goto-anything/context.spec.tsx new file mode 100644 index 0000000000..19ca03e71b --- /dev/null +++ b/web/app/components/goto-anything/context.spec.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { GotoAnythingProvider, useGotoAnythingContext } from './context' + +let pathnameMock = '/' +jest.mock('next/navigation', () => ({ + usePathname: () => pathnameMock, +})) + +let isWorkflowPageMock = false +jest.mock('../workflow/constants', () => ({ + isInWorkflowPage: () => isWorkflowPageMock, +})) + +const ContextConsumer = () => { + const { isWorkflowPage, isRagPipelinePage } = useGotoAnythingContext() + return ( +
+ {String(isWorkflowPage)}|{String(isRagPipelinePage)} +
+ ) +} + +describe('GotoAnythingProvider', () => { + beforeEach(() => { + isWorkflowPageMock = false + pathnameMock = '/' + }) + + test('should set workflow page flag when workflow path detected', async () => { + isWorkflowPageMock = true + pathnameMock = '/app/123/workflow' + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('status')).toHaveTextContent('true|false') + }) + }) + + test('should detect RAG pipeline path based on pathname', async () => { + pathnameMock = '/datasets/abc/pipeline' + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByTestId('status')).toHaveTextContent('false|true') + }) + }) +}) diff --git a/web/app/components/goto-anything/index.spec.tsx b/web/app/components/goto-anything/index.spec.tsx new file mode 100644 index 0000000000..2ffff1cb43 --- /dev/null +++ b/web/app/components/goto-anything/index.spec.tsx @@ -0,0 +1,173 @@ +import React from 'react' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import GotoAnything from './index' +import type { ActionItem, SearchResult } from './actions/types' + +const routerPush = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: routerPush, + }), + usePathname: () => '/', +})) + +const keyPressHandlers: Record void> = {} +jest.mock('ahooks', () => ({ + useDebounce: (value: any) => value, + useKeyPress: (keys: string | string[], handler: (event: any) => void) => { + const keyList = Array.isArray(keys) ? keys : [keys] + keyList.forEach((key) => { + keyPressHandlers[key] = handler + }) + }, +})) + +const triggerKeyPress = (combo: string) => { + const handler = keyPressHandlers[combo] + if (handler) { + act(() => { + handler({ preventDefault: jest.fn(), target: document.body }) + }) + } +} + +let mockQueryResult = { data: [] as SearchResult[], isLoading: false, isError: false, error: null as Error | null } +jest.mock('@tanstack/react-query', () => ({ + useQuery: () => mockQueryResult, +})) + +jest.mock('@/context/i18n', () => ({ + useGetLanguage: () => 'en_US', +})) + +const contextValue = { isWorkflowPage: false, isRagPipelinePage: false } +jest.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: jest.fn(), + search: jest.fn(), +}) + +const actionsMock = { + slash: createActionItem('/', '/'), + app: createActionItem('@app', '@app'), + plugin: createActionItem('@plugin', '@plugin'), +} + +const createActionsMock = jest.fn(() => actionsMock) +const matchActionMock = jest.fn(() => undefined) +const searchAnythingMock = jest.fn(async () => mockQueryResult.data) + +jest.mock('./actions', () => ({ + __esModule: true, + createActions: () => createActionsMock(), + matchAction: () => matchActionMock(), + searchAnything: () => searchAnythingMock(), +})) + +jest.mock('./actions/commands', () => ({ + SlashCommandProvider: () => null, +})) + +jest.mock('./actions/commands/registry', () => ({ + slashCommandRegistry: { + findCommand: () => null, + getAvailableCommands: () => [], + getAllCommands: () => [], + }, +})) + +jest.mock('@/app/components/workflow/utils/common', () => ({ + getKeyboardKeyCodeBySystem: () => 'ctrl', + isEventTargetInputArea: () => false, + isMac: () => false, +})) + +jest.mock('@/app/components/workflow/utils/node-navigation', () => ({ + selectWorkflowNode: jest.fn(), +})) + +jest.mock('../plugins/install-plugin/install-from-marketplace', () => (props: { manifest?: { name?: string }, onClose: () => void }) => ( +
+ {props.manifest?.name} + +
+)) + +describe('GotoAnything', () => { + beforeEach(() => { + routerPush.mockClear() + Object.keys(keyPressHandlers).forEach(key => delete keyPressHandlers[key]) + mockQueryResult = { data: [], isLoading: false, isError: false, error: null } + matchActionMock.mockReset() + searchAnythingMock.mockClear() + }) + + it('should open modal via shortcut and navigate to selected result', async () => { + mockQueryResult = { + data: [{ + id: 'app-1', + type: 'app', + title: 'Sample App', + description: 'desc', + path: '/apps/1', + icon:
🧩
, + data: {}, + } as any], + isLoading: false, + isError: false, + error: null, + } + + render() + + triggerKeyPress('ctrl.k') + + const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder') + await userEvent.type(input, 'app') + + const result = await screen.findByText('Sample App') + await userEvent.click(result) + + expect(routerPush).toHaveBeenCalledWith('/apps/1') + }) + + it('should open plugin installer when selecting plugin result', async () => { + mockQueryResult = { + data: [{ + id: 'plugin-1', + type: 'plugin', + title: 'Plugin Item', + description: 'desc', + path: '', + icon:
, + data: { + name: 'Plugin Item', + latest_package_identifier: 'pkg', + }, + } as any], + isLoading: false, + isError: false, + error: null, + } + + render() + + triggerKeyPress('ctrl.k') + const input = await screen.findByPlaceholderText('app.gotoAnything.searchPlaceholder') + await userEvent.type(input, 'plugin') + + const pluginItem = await screen.findByText('Plugin Item') + await userEvent.click(pluginItem) + + expect(await screen.findByTestId('install-modal')).toHaveTextContent('Plugin Item') + }) +}) diff --git a/web/jest.setup.ts b/web/jest.setup.ts index 006b28322e..02062b4604 100644 --- a/web/jest.setup.ts +++ b/web/jest.setup.ts @@ -4,6 +4,13 @@ import { cleanup } from '@testing-library/react' // Fix for @headlessui/react compatibility with happy-dom // headlessui tries to override focus properties which may be read-only in happy-dom if (typeof window !== 'undefined') { + // Provide a minimal animations API polyfill before @headlessui/react boots + if (typeof Element !== 'undefined' && !Element.prototype.getAnimations) + Element.prototype.getAnimations = () => [] + + if (!document.getAnimations) + document.getAnimations = () => [] + const ensureWritable = (target: object, prop: string) => { const descriptor = Object.getOwnPropertyDescriptor(target, prop) if (descriptor && !descriptor.writable) {