From aa7fe42615b7d0fd4a8fe638e8d57a5959a7f7a8 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Tue, 3 Feb 2026 13:47:30 +0800 Subject: [PATCH] test: enhance CommandSelector and GotoAnythingProvider tests (#31743) Co-authored-by: CodingOnStar --- .../app/create-app-modal/index.spec.tsx | 4 +- .../explore/create-app-modal/index.spec.tsx | 32 +- .../goto-anything/command-selector.spec.tsx | 201 ++++++ .../components/empty-state.spec.tsx | 157 +++++ .../goto-anything/components/empty-state.tsx | 105 ++++ .../goto-anything/components/footer.spec.tsx | 273 ++++++++ .../goto-anything/components/footer.tsx | 90 +++ .../goto-anything/components/index.ts | 14 + .../goto-anything/components/result-item.tsx | 38 ++ .../goto-anything/components/result-list.tsx | 49 ++ .../components/search-input.spec.tsx | 206 ++++++ .../goto-anything/components/search-input.tsx | 62 ++ .../components/goto-anything/context.spec.tsx | 77 ++- .../components/goto-anything/hooks/index.ts | 11 + .../hooks/use-goto-anything-modal.spec.ts | 291 +++++++++ .../hooks/use-goto-anything-modal.ts | 59 ++ .../use-goto-anything-navigation.spec.ts | 391 ++++++++++++ .../hooks/use-goto-anything-navigation.ts | 96 +++ .../hooks/use-goto-anything-results.spec.ts | 354 +++++++++++ .../hooks/use-goto-anything-results.ts | 115 ++++ .../hooks/use-goto-anything-search.spec.ts | 301 +++++++++ .../hooks/use-goto-anything-search.ts | 77 +++ .../components/goto-anything/index.spec.tsx | 581 +++++++++++++++-- web/app/components/goto-anything/index.tsx | 585 +++++------------- .../workflow-onboarding-modal/index.spec.tsx | 4 +- web/eslint-suppressions.json | 10 - 26 files changed, 3666 insertions(+), 517 deletions(-) create mode 100644 web/app/components/goto-anything/components/empty-state.spec.tsx create mode 100644 web/app/components/goto-anything/components/empty-state.tsx create mode 100644 web/app/components/goto-anything/components/footer.spec.tsx create mode 100644 web/app/components/goto-anything/components/footer.tsx create mode 100644 web/app/components/goto-anything/components/index.ts create mode 100644 web/app/components/goto-anything/components/result-item.tsx create mode 100644 web/app/components/goto-anything/components/result-list.tsx create mode 100644 web/app/components/goto-anything/components/search-input.spec.tsx create mode 100644 web/app/components/goto-anything/components/search-input.tsx create mode 100644 web/app/components/goto-anything/hooks/index.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-modal.spec.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-modal.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-navigation.spec.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-results.spec.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-results.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-search.spec.ts create mode 100644 web/app/components/goto-anything/hooks/use-goto-anything-search.ts diff --git a/web/app/components/app/create-app-modal/index.spec.tsx b/web/app/components/app/create-app-modal/index.spec.tsx index cb8f4db67f..d26a581fda 100644 --- a/web/app/components/app/create-app-modal/index.spec.tsx +++ b/web/app/components/app/create-app-modal/index.spec.tsx @@ -124,7 +124,7 @@ describe('CreateAppModal', () => { const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder') fireEvent.change(nameInput, { target: { value: 'My App' } }) - fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' })) + fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ })) await waitFor(() => expect(mockCreateApp).toHaveBeenCalledWith({ name: 'My App', @@ -152,7 +152,7 @@ describe('CreateAppModal', () => { const nameInput = screen.getByPlaceholderText('app.newApp.appNamePlaceholder') fireEvent.change(nameInput, { target: { value: 'My App' } }) - fireEvent.click(screen.getByRole('button', { name: 'app.newApp.Create' })) + fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Create/ })) await waitFor(() => expect(mockCreateApp).toHaveBeenCalled()) expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'boom' }) diff --git a/web/app/components/explore/create-app-modal/index.spec.tsx b/web/app/components/explore/create-app-modal/index.spec.tsx index 7ddb5a9082..65ec0e6096 100644 --- a/web/app/components/explore/create-app-modal/index.spec.tsx +++ b/web/app/components/explore/create-app-modal/index.spec.tsx @@ -138,7 +138,7 @@ describe('CreateAppModal', () => { setup({ appName: 'My App', isEditModal: false }) expect(screen.getByText('explore.appCustomize.title:{"name":"My App"}')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument() }) @@ -146,7 +146,7 @@ describe('CreateAppModal', () => { setup({ isEditModal: true, appMode: AppModeEnum.CHAT, max_active_requests: 5 }) expect(screen.getByText('app.editAppTitle')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeInTheDocument() expect(screen.getByRole('switch')).toBeInTheDocument() expect((screen.getByRole('spinbutton') as HTMLInputElement).value).toBe('5') }) @@ -166,7 +166,7 @@ describe('CreateAppModal', () => { it('should not render modal content when hidden', () => { setup({ show: false }) - expect(screen.queryByRole('button', { name: 'common.operation.create' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /common\.operation\.create/ })).not.toBeInTheDocument() }) }) @@ -175,13 +175,13 @@ describe('CreateAppModal', () => { it('should disable confirm action when confirmDisabled is true', () => { setup({ confirmDisabled: true }) - expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled() + expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled() }) it('should disable confirm action when appName is empty', () => { setup({ appName: ' ' }) - expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled() + expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled() }) }) @@ -245,7 +245,7 @@ describe('CreateAppModal', () => { setup({ isEditModal: false }) expect(screen.getByText('billing.apps.fullTip2')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.create' })).toBeDisabled() + expect(screen.getByRole('button', { name: /common\.operation\.create/ })).toBeDisabled() }) it('should allow saving when apps quota is reached in edit mode', () => { @@ -257,7 +257,7 @@ describe('CreateAppModal', () => { setup({ isEditModal: true }) expect(screen.queryByText('billing.apps.fullTip2')).not.toBeInTheDocument() - expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeEnabled() + expect(screen.getByRole('button', { name: /common\.operation\.save/ })).toBeEnabled() }) }) @@ -384,7 +384,7 @@ describe('CreateAppModal', () => { fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' })) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -433,7 +433,7 @@ describe('CreateAppModal', () => { expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument() // Submit and verify the payload uses the original icon (cancel reverts to props) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -471,7 +471,7 @@ describe('CreateAppModal', () => { appIconBackground: '#000000', }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -495,7 +495,7 @@ describe('CreateAppModal', () => { const { onConfirm } = setup({ appDescription: 'Old description' }) fireEvent.change(screen.getByPlaceholderText('app.newApp.appDescriptionPlaceholder'), { target: { value: 'Updated description' } }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -512,7 +512,7 @@ describe('CreateAppModal', () => { appIconBackground: null, }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -536,7 +536,7 @@ describe('CreateAppModal', () => { fireEvent.click(screen.getByRole('switch')) fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '12' } }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -551,7 +551,7 @@ describe('CreateAppModal', () => { it('should omit max_active_requests when input is empty', () => { const { onConfirm } = setup({ isEditModal: true, max_active_requests: null }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -564,7 +564,7 @@ describe('CreateAppModal', () => { const { onConfirm } = setup({ isEditModal: true, max_active_requests: null }) fireEvent.change(screen.getByRole('spinbutton'), { target: { value: 'abc' } }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/ })) act(() => { vi.advanceTimersByTime(300) }) @@ -576,7 +576,7 @@ describe('CreateAppModal', () => { it('should show toast error and not submit when name becomes empty before debounced submit runs', () => { const { onConfirm, onHide } = setup({ appName: 'My App' }) - fireEvent.click(screen.getByRole('button', { name: 'common.operation.create' })) + fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), { target: { value: ' ' } }) act(() => { diff --git a/web/app/components/goto-anything/command-selector.spec.tsx b/web/app/components/goto-anything/command-selector.spec.tsx index 0ee2086058..0712a1afd6 100644 --- a/web/app/components/goto-anything/command-selector.spec.tsx +++ b/web/app/components/goto-anything/command-selector.spec.tsx @@ -81,4 +81,205 @@ describe('CommandSelector', () => { expect(onSelect).toHaveBeenCalledWith('/zen') }) + + it('should show all slash commands when no filter provided', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + // Should show the zen command from mock + expect(screen.getByText('/zen')).toBeInTheDocument() + }) + + it('should exclude slash action when in @ mode', () => { + const actions = { + ...createActions(), + slash: { + key: '/', + shortcut: '/', + title: 'Slash', + search: vi.fn(), + description: '', + } as ActionItem, + } + const onSelect = vi.fn() + + render( + + + , + ) + + // Should show @ commands but not / + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.queryByText('/')).not.toBeInTheDocument() + }) + + it('should show all actions when no filter in @ mode', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('@app')).toBeInTheDocument() + expect(screen.getByText('@plugin')).toBeInTheDocument() + }) + + it('should set default command value when items exist but value does not', () => { + const actions = createActions() + const onSelect = vi.fn() + const onCommandValueChange = vi.fn() + + render( + + + , + ) + + expect(onCommandValueChange).toHaveBeenCalledWith('@app') + }) + + it('should NOT set command value when value already exists in items', () => { + const actions = createActions() + const onSelect = vi.fn() + const onCommandValueChange = vi.fn() + + render( + + + , + ) + + expect(onCommandValueChange).not.toHaveBeenCalled() + }) + + it('should show no matching commands message when filter has no results', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.tryDifferentSearch')).toBeInTheDocument() + }) + + it('should show no matching commands for slash mode with no results', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('app.gotoAnything.noMatchingCommands')).toBeInTheDocument() + }) + + it('should render description for @ commands', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('app.gotoAnything.actions.searchApplicationsDesc')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.actions.searchPluginsDesc')).toBeInTheDocument() + }) + + it('should render group header for @ mode', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('app.gotoAnything.selectSearchType')).toBeInTheDocument() + }) + + it('should render group header for slash mode', () => { + const actions = createActions() + const onSelect = vi.fn() + + render( + + + , + ) + + expect(screen.getByText('app.gotoAnything.groups.commands')).toBeInTheDocument() + }) }) diff --git a/web/app/components/goto-anything/components/empty-state.spec.tsx b/web/app/components/goto-anything/components/empty-state.spec.tsx new file mode 100644 index 0000000000..e1e5e0dc89 --- /dev/null +++ b/web/app/components/goto-anything/components/empty-state.spec.tsx @@ -0,0 +1,157 @@ +import { render, screen } from '@testing-library/react' +import EmptyState from './empty-state' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string, shortcuts?: string }) => { + if (options?.shortcuts !== undefined) + return `${key}:${options.shortcuts}` + return `${options?.ns || 'common'}.${key}` + }, + }), +})) + +describe('EmptyState', () => { + describe('loading variant', () => { + it('should render loading spinner', () => { + render() + + expect(screen.getByText('app.gotoAnything.searching')).toBeInTheDocument() + }) + + it('should have spinner animation class', () => { + const { container } = render() + + const spinner = container.querySelector('.animate-spin') + expect(spinner).toBeInTheDocument() + }) + }) + + describe('error variant', () => { + it('should render error message when error has message', () => { + const error = new Error('Connection failed') + render() + + expect(screen.getByText('app.gotoAnything.searchFailed')).toBeInTheDocument() + expect(screen.getByText('Connection failed')).toBeInTheDocument() + }) + + it('should render generic error when error has no message', () => { + render() + + expect(screen.getByText('app.gotoAnything.searchTemporarilyUnavailable')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.servicesUnavailableMessage')).toBeInTheDocument() + }) + + it('should render generic error when error is undefined', () => { + render() + + expect(screen.getByText('app.gotoAnything.searchTemporarilyUnavailable')).toBeInTheDocument() + }) + + it('should have red error text styling', () => { + const error = new Error('Test error') + const { container } = render() + + const errorText = container.querySelector('.text-red-500') + expect(errorText).toBeInTheDocument() + }) + }) + + describe('default variant', () => { + it('should render search title', () => { + render() + + expect(screen.getByText('app.gotoAnything.searchTitle')).toBeInTheDocument() + }) + + it('should render all hint messages', () => { + render() + + expect(screen.getByText('app.gotoAnything.searchHint')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.commandHint')).toBeInTheDocument() + expect(screen.getByText('app.gotoAnything.slashHint')).toBeInTheDocument() + }) + }) + + describe('no-results variant', () => { + describe('general search mode', () => { + it('should render generic no results message', () => { + render() + + expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument() + }) + + it('should show specific search hint with shortcuts', () => { + const Actions = { + app: { key: '@app', shortcut: '@app' }, + plugin: { key: '@plugin', shortcut: '@plugin' }, + } as unknown as Record + render() + + expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:@app, @plugin')).toBeInTheDocument() + }) + }) + + describe('app search mode', () => { + it('should render no apps found message', () => { + render() + + expect(screen.getByText('app.gotoAnything.emptyState.noAppsFound')).toBeInTheDocument() + }) + + it('should show try different term hint', () => { + render() + + expect(screen.getByText('app.gotoAnything.emptyState.tryDifferentTerm')).toBeInTheDocument() + }) + }) + + describe('plugin search mode', () => { + it('should render no plugins found message', () => { + render() + + expect(screen.getByText('app.gotoAnything.emptyState.noPluginsFound')).toBeInTheDocument() + }) + }) + + describe('knowledge search mode', () => { + it('should render no knowledge bases found message', () => { + render() + + expect(screen.getByText('app.gotoAnything.emptyState.noKnowledgeBasesFound')).toBeInTheDocument() + }) + }) + + describe('node search mode', () => { + it('should render no workflow nodes found message', () => { + render() + + expect(screen.getByText('app.gotoAnything.emptyState.noWorkflowNodesFound')).toBeInTheDocument() + }) + }) + + describe('unknown search mode', () => { + it('should fallback to generic no results message', () => { + render() + + expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument() + }) + }) + }) + + describe('default props', () => { + it('should use general as default searchMode', () => { + render() + + expect(screen.getByText('app.gotoAnything.noResults')).toBeInTheDocument() + }) + + it('should use empty object as default Actions', () => { + render() + + // Should show empty shortcuts + expect(screen.getByText('gotoAnything.emptyState.trySpecificSearch:')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/goto-anything/components/empty-state.tsx b/web/app/components/goto-anything/components/empty-state.tsx new file mode 100644 index 0000000000..a07bc1d45a --- /dev/null +++ b/web/app/components/goto-anything/components/empty-state.tsx @@ -0,0 +1,105 @@ +'use client' + +import type { FC } from 'react' +import type { ActionItem } from '../actions/types' +import { useTranslation } from 'react-i18next' + +export type EmptyStateVariant = 'no-results' | 'error' | 'default' | 'loading' + +export type EmptyStateProps = { + variant: EmptyStateVariant + searchMode?: string + error?: Error | null + Actions?: Record +} + +const EmptyState: FC = ({ + variant, + searchMode = 'general', + error, + Actions = {}, +}) => { + const { t } = useTranslation() + + if (variant === 'loading') { + return ( +
+
+
+ {t('gotoAnything.searching', { ns: 'app' })} +
+
+ ) + } + + if (variant === 'error') { + return ( +
+
+
+ {error?.message + ? t('gotoAnything.searchFailed', { ns: 'app' }) + : t('gotoAnything.searchTemporarilyUnavailable', { ns: 'app' })} +
+
+ {error?.message || t('gotoAnything.servicesUnavailableMessage', { ns: 'app' })} +
+
+
+ ) + } + + if (variant === 'default') { + return ( +
+
+
{t('gotoAnything.searchTitle', { ns: 'app' })}
+
+
{t('gotoAnything.searchHint', { ns: 'app' })}
+
{t('gotoAnything.commandHint', { ns: 'app' })}
+
{t('gotoAnything.slashHint', { ns: 'app' })}
+
+
+
+ ) + } + + // variant === 'no-results' + const isCommandSearch = searchMode !== 'general' + const commandType = isCommandSearch ? searchMode.replace('@', '') : '' + + const getNoResultsMessage = () => { + if (!isCommandSearch) { + return t('gotoAnything.noResults', { ns: 'app' }) + } + + const keyMap = { + app: 'gotoAnything.emptyState.noAppsFound', + plugin: 'gotoAnything.emptyState.noPluginsFound', + knowledge: 'gotoAnything.emptyState.noKnowledgeBasesFound', + node: 'gotoAnything.emptyState.noWorkflowNodesFound', + } as const + + return t(keyMap[commandType as keyof typeof keyMap] || 'gotoAnything.noResults', { ns: 'app' }) + } + + const getHintMessage = () => { + if (isCommandSearch) { + return t('gotoAnything.emptyState.tryDifferentTerm', { ns: 'app' }) + } + + const shortcuts = Object.values(Actions).map(action => action.shortcut).join(', ') + return t('gotoAnything.emptyState.trySpecificSearch', { ns: 'app', shortcuts }) + } + + return ( +
+
+
{getNoResultsMessage()}
+
{getHintMessage()}
+
+
+ ) +} + +export default EmptyState diff --git a/web/app/components/goto-anything/components/footer.spec.tsx b/web/app/components/goto-anything/components/footer.spec.tsx new file mode 100644 index 0000000000..3dfac5f71c --- /dev/null +++ b/web/app/components/goto-anything/components/footer.spec.tsx @@ -0,0 +1,273 @@ +import { render, screen } from '@testing-library/react' +import Footer from './footer' + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { ns?: string, count?: number, scope?: string }) => { + if (options?.count !== undefined) + return `${key}:${options.count}` + if (options?.scope) + return `${key}:${options.scope}` + return `${options?.ns || 'common'}.${key}` + }, + }), +})) + +describe('Footer', () => { + describe('left content', () => { + describe('when there are results', () => { + it('should show result count', () => { + render( +