fix: unified error handling for GotoAnything search actions (#23715)
This commit is contained in:
197
web/__tests__/goto-anything/search-error-handling.test.ts
Normal file
197
web/__tests__/goto-anything/search-error-handling.test.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* Test GotoAnything search error handling mechanisms
|
||||||
|
*
|
||||||
|
* Main validations:
|
||||||
|
* 1. @plugin search error handling when API fails
|
||||||
|
* 2. Regular search (without @prefix) error handling when API fails
|
||||||
|
* 3. Verify consistent error handling across different search types
|
||||||
|
* 4. Ensure errors don't propagate to UI layer causing "search failed"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Actions, searchAnything } from '@/app/components/goto-anything/actions'
|
||||||
|
import { postMarketplace } from '@/service/base'
|
||||||
|
import { fetchAppList } from '@/service/apps'
|
||||||
|
import { fetchDatasets } from '@/service/datasets'
|
||||||
|
|
||||||
|
// Mock API functions
|
||||||
|
jest.mock('@/service/base', () => ({
|
||||||
|
postMarketplace: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/service/apps', () => ({
|
||||||
|
fetchAppList: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.mock('@/service/datasets', () => ({
|
||||||
|
fetchDatasets: jest.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockPostMarketplace = postMarketplace as jest.MockedFunction<typeof postMarketplace>
|
||||||
|
const mockFetchAppList = fetchAppList as jest.MockedFunction<typeof fetchAppList>
|
||||||
|
const mockFetchDatasets = fetchDatasets as jest.MockedFunction<typeof fetchDatasets>
|
||||||
|
|
||||||
|
describe('GotoAnything Search Error Handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
// Suppress console.warn for clean test output
|
||||||
|
jest.spyOn(console, 'warn').mockImplementation(() => {
|
||||||
|
// Suppress console.warn for clean test output
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('@plugin search error handling', () => {
|
||||||
|
it('should return empty array when API fails instead of throwing error', async () => {
|
||||||
|
// Mock marketplace API failure (403 permission denied)
|
||||||
|
mockPostMarketplace.mockRejectedValue(new Error('HTTP 403: Forbidden'))
|
||||||
|
|
||||||
|
const pluginAction = Actions.plugin
|
||||||
|
|
||||||
|
// Directly call plugin action's search method
|
||||||
|
const result = await pluginAction.search('@plugin', 'test', 'en')
|
||||||
|
|
||||||
|
// Should return empty array instead of throwing error
|
||||||
|
expect(result).toEqual([])
|
||||||
|
expect(mockPostMarketplace).toHaveBeenCalledWith('/plugins/search/advanced', {
|
||||||
|
body: {
|
||||||
|
page: 1,
|
||||||
|
page_size: 10,
|
||||||
|
query: 'test',
|
||||||
|
type: 'plugin',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty array when user has no plugin data', async () => {
|
||||||
|
// Mock marketplace returning empty data
|
||||||
|
mockPostMarketplace.mockResolvedValue({
|
||||||
|
data: { plugins: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const pluginAction = Actions.plugin
|
||||||
|
const result = await pluginAction.search('@plugin', '', 'en')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty array when API returns unexpected data structure', async () => {
|
||||||
|
// Mock API returning unexpected data structure
|
||||||
|
mockPostMarketplace.mockResolvedValue({
|
||||||
|
data: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pluginAction = Actions.plugin
|
||||||
|
const result = await pluginAction.search('@plugin', 'test', 'en')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Other search types error handling', () => {
|
||||||
|
it('@app search should return empty array when API fails', async () => {
|
||||||
|
// Mock app API failure
|
||||||
|
mockFetchAppList.mockRejectedValue(new Error('API Error'))
|
||||||
|
|
||||||
|
const appAction = Actions.app
|
||||||
|
const result = await appAction.search('@app', 'test', 'en')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('@knowledge search should return empty array when API fails', async () => {
|
||||||
|
// Mock knowledge API failure
|
||||||
|
mockFetchDatasets.mockRejectedValue(new Error('API Error'))
|
||||||
|
|
||||||
|
const knowledgeAction = Actions.knowledge
|
||||||
|
const result = await knowledgeAction.search('@knowledge', 'test', 'en')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Unified search entry error handling', () => {
|
||||||
|
it('regular search (without @prefix) should return successful results even when partial APIs fail', async () => {
|
||||||
|
// Set app and knowledge success, plugin failure
|
||||||
|
mockFetchAppList.mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 })
|
||||||
|
mockFetchDatasets.mockResolvedValue({ data: [], has_more: false, limit: 10, page: 1, total: 0 })
|
||||||
|
mockPostMarketplace.mockRejectedValue(new Error('Plugin API failed'))
|
||||||
|
|
||||||
|
const result = await searchAnything('en', 'test')
|
||||||
|
|
||||||
|
// Should return successful results even if plugin search fails
|
||||||
|
expect(result).toEqual([])
|
||||||
|
expect(console.warn).toHaveBeenCalledWith('Plugin search failed:', expect.any(Error))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('@plugin dedicated search should return empty array when API fails', async () => {
|
||||||
|
// Mock plugin API failure
|
||||||
|
mockPostMarketplace.mockRejectedValue(new Error('Plugin service unavailable'))
|
||||||
|
|
||||||
|
const pluginAction = Actions.plugin
|
||||||
|
const result = await searchAnything('en', '@plugin test', pluginAction)
|
||||||
|
|
||||||
|
// Should return empty array instead of throwing error
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('@app dedicated search should return empty array when API fails', async () => {
|
||||||
|
// Mock app API failure
|
||||||
|
mockFetchAppList.mockRejectedValue(new Error('App service unavailable'))
|
||||||
|
|
||||||
|
const appAction = Actions.app
|
||||||
|
const result = await searchAnything('en', '@app test', appAction)
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error handling consistency validation', () => {
|
||||||
|
it('all search types should return empty array when encountering errors', async () => {
|
||||||
|
// Mock all APIs to fail
|
||||||
|
mockPostMarketplace.mockRejectedValue(new Error('Plugin API failed'))
|
||||||
|
mockFetchAppList.mockRejectedValue(new Error('App API failed'))
|
||||||
|
mockFetchDatasets.mockRejectedValue(new Error('Dataset API failed'))
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{ name: '@plugin', action: Actions.plugin },
|
||||||
|
{ name: '@app', action: Actions.app },
|
||||||
|
{ name: '@knowledge', action: Actions.knowledge },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const { name, action } of actions) {
|
||||||
|
const result = await action.search(name, 'test', 'en')
|
||||||
|
expect(result).toEqual([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Edge case testing', () => {
|
||||||
|
it('empty search term should be handled properly', async () => {
|
||||||
|
mockPostMarketplace.mockResolvedValue({ data: { plugins: [] } })
|
||||||
|
|
||||||
|
const result = await searchAnything('en', '@plugin ', Actions.plugin)
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('network timeout should be handled correctly', async () => {
|
||||||
|
const timeoutError = new Error('Network timeout')
|
||||||
|
timeoutError.name = 'TimeoutError'
|
||||||
|
|
||||||
|
mockPostMarketplace.mockRejectedValue(timeoutError)
|
||||||
|
|
||||||
|
const result = await searchAnything('en', '@plugin test', Actions.plugin)
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('JSON parsing errors should be handled correctly', async () => {
|
||||||
|
const parseError = new SyntaxError('Unexpected token in JSON')
|
||||||
|
mockPostMarketplace.mockRejectedValue(parseError)
|
||||||
|
|
||||||
|
const result = await searchAnything('en', '@plugin test', Actions.plugin)
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -38,16 +38,21 @@ export const appAction: ActionItem = {
|
|||||||
title: 'Search Applications',
|
title: 'Search Applications',
|
||||||
description: 'Search and navigate to your applications',
|
description: 'Search and navigate to your applications',
|
||||||
// action,
|
// action,
|
||||||
search: async (_, searchTerm = '', locale) => {
|
search: async (_, searchTerm = '', _locale) => {
|
||||||
const response = (await fetchAppList({
|
try {
|
||||||
url: 'apps',
|
const response = await fetchAppList({
|
||||||
params: {
|
url: 'apps',
|
||||||
page: 1,
|
params: {
|
||||||
name: searchTerm,
|
page: 1,
|
||||||
},
|
name: searchTerm,
|
||||||
}))
|
},
|
||||||
const apps = response.data || []
|
})
|
||||||
|
const apps = response?.data || []
|
||||||
return parser(apps)
|
return parser(apps)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn('App search failed:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ export const searchAnything = async (
|
|||||||
): Promise<SearchResult[]> => {
|
): Promise<SearchResult[]> => {
|
||||||
if (actionItem) {
|
if (actionItem) {
|
||||||
const searchTerm = query.replace(actionItem.key, '').replace(actionItem.shortcut, '').trim()
|
const searchTerm = query.replace(actionItem.key, '').replace(actionItem.shortcut, '').trim()
|
||||||
return await actionItem.search(query, searchTerm, locale)
|
try {
|
||||||
|
return await actionItem.search(query, searchTerm, locale)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn(`Search failed for ${actionItem.key}:`, error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.startsWith('@'))
|
if (query.startsWith('@'))
|
||||||
|
|||||||
@@ -35,16 +35,22 @@ export const knowledgeAction: ActionItem = {
|
|||||||
title: 'Search Knowledge Bases',
|
title: 'Search Knowledge Bases',
|
||||||
description: 'Search and navigate to your knowledge bases',
|
description: 'Search and navigate to your knowledge bases',
|
||||||
// action,
|
// action,
|
||||||
search: async (_, searchTerm = '', locale) => {
|
search: async (_, searchTerm = '', _locale) => {
|
||||||
const response = await fetchDatasets({
|
try {
|
||||||
url: '/datasets',
|
const response = await fetchDatasets({
|
||||||
params: {
|
url: '/datasets',
|
||||||
page: 1,
|
params: {
|
||||||
limit: 10,
|
page: 1,
|
||||||
keyword: searchTerm,
|
limit: 10,
|
||||||
},
|
keyword: searchTerm,
|
||||||
})
|
},
|
||||||
|
})
|
||||||
return parser(response.data)
|
const datasets = response?.data || []
|
||||||
|
return parser(datasets)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn('Knowledge search failed:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,18 +24,30 @@ export const pluginAction: ActionItem = {
|
|||||||
title: 'Search Plugins',
|
title: 'Search Plugins',
|
||||||
description: 'Search and navigate to your plugins',
|
description: 'Search and navigate to your plugins',
|
||||||
search: async (_, searchTerm = '', locale) => {
|
search: async (_, searchTerm = '', locale) => {
|
||||||
const response = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>('/plugins/search/advanced', {
|
try {
|
||||||
body: {
|
const response = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>('/plugins/search/advanced', {
|
||||||
page: 1,
|
body: {
|
||||||
page_size: 10,
|
page: 1,
|
||||||
query: searchTerm,
|
page_size: 10,
|
||||||
type: 'plugin',
|
query: searchTerm,
|
||||||
},
|
type: 'plugin',
|
||||||
})
|
},
|
||||||
const list = (response.data.plugins || []).map(plugin => ({
|
})
|
||||||
...plugin,
|
|
||||||
icon: getPluginIconInMarketplace(plugin),
|
if (!response?.data?.plugins) {
|
||||||
}))
|
console.warn('Plugin search: Unexpected response structure', response)
|
||||||
return parser(list, locale!)
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = response.data.plugins.map(plugin => ({
|
||||||
|
...plugin,
|
||||||
|
icon: getPluginIconInMarketplace(plugin),
|
||||||
|
}))
|
||||||
|
return parser(list, locale!)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn('Plugin search failed:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { ActionItem } from './types'
|
import type { ActionItem } from './types'
|
||||||
import { BoltIcon } from '@heroicons/react/24/outline'
|
|
||||||
import i18n from 'i18next'
|
|
||||||
|
|
||||||
// Create the workflow nodes action
|
// Create the workflow nodes action
|
||||||
export const workflowNodesAction: ActionItem = {
|
export const workflowNodesAction: ActionItem = {
|
||||||
@@ -12,32 +10,14 @@ export const workflowNodesAction: ActionItem = {
|
|||||||
search: async (_, searchTerm = '', locale) => {
|
search: async (_, searchTerm = '', locale) => {
|
||||||
try {
|
try {
|
||||||
// Use the searchFn if available (set by useWorkflowSearch hook)
|
// Use the searchFn if available (set by useWorkflowSearch hook)
|
||||||
if (workflowNodesAction.searchFn) {
|
if (workflowNodesAction.searchFn)
|
||||||
// searchFn already returns SearchResult[] type, no need to use parser
|
|
||||||
return workflowNodesAction.searchFn(searchTerm)
|
return workflowNodesAction.searchFn(searchTerm)
|
||||||
}
|
|
||||||
|
|
||||||
// If not in workflow context or search function not registered
|
|
||||||
if (!searchTerm.trim()) {
|
|
||||||
return [{
|
|
||||||
id: 'help',
|
|
||||||
title: i18n.t('app.gotoAnything.actions.searchWorkflowNodes', { lng: locale }),
|
|
||||||
description: i18n.t('app.gotoAnything.actions.searchWorkflowNodesHelp', { lng: locale }),
|
|
||||||
type: 'workflow-node',
|
|
||||||
path: '#',
|
|
||||||
data: {} as any,
|
|
||||||
icon: (
|
|
||||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-blue-50 text-blue-600">
|
|
||||||
<BoltIcon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// If not in workflow context, return empty array
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error('Error searching workflow nodes:', error)
|
console.warn('Workflow nodes search failed:', error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ export const useWorkflowSearch = () => {
|
|||||||
|
|
||||||
// Create search function for workflow nodes
|
// Create search function for workflow nodes
|
||||||
const searchWorkflowNodes = useCallback((query: string) => {
|
const searchWorkflowNodes = useCallback((query: string) => {
|
||||||
if (!searchableNodes.length || !query.trim()) return []
|
if (!searchableNodes.length) return []
|
||||||
|
|
||||||
const searchTerm = query.toLowerCase()
|
const searchTerm = query.toLowerCase().trim()
|
||||||
|
|
||||||
const results = searchableNodes
|
const results = searchableNodes
|
||||||
.map((node) => {
|
.map((node) => {
|
||||||
@@ -58,11 +58,18 @@ export const useWorkflowSearch = () => {
|
|||||||
|
|
||||||
let score = 0
|
let score = 0
|
||||||
|
|
||||||
if (titleMatch.startsWith(searchTerm)) score += 100
|
// If no search term, show all nodes with base score
|
||||||
else if (titleMatch.includes(searchTerm)) score += 50
|
if (!searchTerm) {
|
||||||
else if (typeMatch === searchTerm) score += 80
|
score = 1
|
||||||
else if (typeMatch.includes(searchTerm)) score += 30
|
}
|
||||||
else if (descMatch.includes(searchTerm)) score += 20
|
else {
|
||||||
|
// Score based on search relevance
|
||||||
|
if (titleMatch.startsWith(searchTerm)) score += 100
|
||||||
|
else if (titleMatch.includes(searchTerm)) score += 50
|
||||||
|
else if (typeMatch === searchTerm) score += 80
|
||||||
|
else if (typeMatch.includes(searchTerm)) score += 30
|
||||||
|
else if (descMatch.includes(searchTerm)) score += 20
|
||||||
|
}
|
||||||
|
|
||||||
return score > 0
|
return score > 0
|
||||||
? {
|
? {
|
||||||
@@ -89,6 +96,11 @@ export const useWorkflowSearch = () => {
|
|||||||
})
|
})
|
||||||
.filter((node): node is NonNullable<typeof node> => node !== null)
|
.filter((node): node is NonNullable<typeof node> => node !== null)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
// If no search term, sort alphabetically
|
||||||
|
if (!searchTerm)
|
||||||
|
return a.title.localeCompare(b.title)
|
||||||
|
|
||||||
|
// Sort by relevance when searching
|
||||||
const aTitle = a.title.toLowerCase()
|
const aTitle = a.title.toLowerCase()
|
||||||
const bTitle = b.title.toLowerCase()
|
const bTitle = b.title.toLowerCase()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user