mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 10:12:11 +00:00
[Manager] Add registry search fallback with gateway pattern (#4187)
This commit is contained in:
@@ -32,6 +32,7 @@
|
|||||||
v-model:sortField="sortField"
|
v-model:sortField="sortField"
|
||||||
:search-results="searchResults"
|
:search-results="searchResults"
|
||||||
:suggestions="suggestions"
|
:suggestions="suggestions"
|
||||||
|
:sort-options="sortOptions"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 overflow-auto">
|
||||||
<div
|
<div
|
||||||
@@ -179,7 +180,8 @@ const {
|
|||||||
searchResults,
|
searchResults,
|
||||||
searchMode,
|
searchMode,
|
||||||
sortField,
|
sortField,
|
||||||
suggestions
|
suggestions,
|
||||||
|
sortOptions
|
||||||
} = useRegistrySearch({
|
} = useRegistrySearch({
|
||||||
initialSortField: initialState.sortField,
|
initialSortField: initialState.sortField,
|
||||||
initialSearchMode: initialState.searchMode,
|
initialSearchMode: initialState.searchMode,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
/>
|
/>
|
||||||
<SearchFilterDropdown
|
<SearchFilterDropdown
|
||||||
v-model:modelValue="sortField"
|
v-model:modelValue="sortField"
|
||||||
:options="sortOptions"
|
:options="availableSortOptions"
|
||||||
:label="$t('g.sort')"
|
:label="$t('g.sort')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,21 +56,26 @@ import { computed } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
|
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
|
||||||
import type { NodesIndexSuggestion } from '@/types/algoliaTypes'
|
|
||||||
import {
|
import {
|
||||||
type SearchOption,
|
type SearchOption,
|
||||||
SortableAlgoliaField
|
SortableAlgoliaField
|
||||||
} from '@/types/comfyManagerTypes'
|
} from '@/types/comfyManagerTypes'
|
||||||
import { components } from '@/types/comfyRegistryTypes'
|
import { components } from '@/types/comfyRegistryTypes'
|
||||||
|
import type {
|
||||||
|
QuerySuggestion,
|
||||||
|
SearchMode,
|
||||||
|
SortableField
|
||||||
|
} from '@/types/searchServiceTypes'
|
||||||
|
|
||||||
const { searchResults } = defineProps<{
|
const { searchResults, sortOptions } = defineProps<{
|
||||||
searchResults?: components['schemas']['Node'][]
|
searchResults?: components['schemas']['Node'][]
|
||||||
suggestions?: NodesIndexSuggestion[]
|
suggestions?: QuerySuggestion[]
|
||||||
|
sortOptions?: SortableField[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const searchQuery = defineModel<string>('searchQuery')
|
const searchQuery = defineModel<string>('searchQuery')
|
||||||
const searchMode = defineModel<string>('searchMode', { default: 'packs' })
|
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
|
||||||
const sortField = defineModel<SortableAlgoliaField>('sortField', {
|
const sortField = defineModel<string>('sortField', {
|
||||||
default: SortableAlgoliaField.Downloads
|
default: SortableAlgoliaField.Downloads
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,18 +85,19 @@ const hasResults = computed(
|
|||||||
() => searchQuery.value?.trim() && searchResults?.length
|
() => searchQuery.value?.trim() && searchResults?.length
|
||||||
)
|
)
|
||||||
|
|
||||||
const sortOptions: SearchOption<SortableAlgoliaField>[] = [
|
const availableSortOptions = computed<SearchOption<string>[]>(() => {
|
||||||
{ id: SortableAlgoliaField.Downloads, label: t('manager.sort.downloads') },
|
if (!sortOptions) return []
|
||||||
{ id: SortableAlgoliaField.Created, label: t('manager.sort.created') },
|
return sortOptions.map((field) => ({
|
||||||
{ id: SortableAlgoliaField.Updated, label: t('manager.sort.updated') },
|
id: field.id,
|
||||||
{ id: SortableAlgoliaField.Publisher, label: t('manager.sort.publisher') },
|
label: field.label
|
||||||
{ id: SortableAlgoliaField.Name, label: t('g.name') }
|
}))
|
||||||
]
|
})
|
||||||
const filterOptions: SearchOption<string>[] = [
|
const filterOptions: SearchOption<SearchMode>[] = [
|
||||||
{ id: 'packs', label: t('manager.filter.nodePack') },
|
{ id: 'packs', label: t('manager.filter.nodePack') },
|
||||||
{ id: 'nodes', label: t('g.nodes') }
|
{ id: 'nodes', label: t('g.nodes') }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// When a dropdown query suggestion is selected, update the search query
|
||||||
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
|
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
|
||||||
searchQuery.value = event.value.query
|
searchQuery.value = event.value.query
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +1,61 @@
|
|||||||
import { watchDebounced } from '@vueuse/core'
|
import { watchDebounced } from '@vueuse/core'
|
||||||
import type { Hit } from 'algoliasearch/dist/lite/browser'
|
import { orderBy } from 'lodash'
|
||||||
import { memoize, orderBy } from 'lodash'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { useAlgoliaSearchService } from '@/services/algoliaSearchService'
|
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
|
||||||
import type {
|
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
|
||||||
AlgoliaNodePack,
|
import type { SearchAttribute } from '@/types/algoliaTypes'
|
||||||
NodesIndexSuggestion,
|
|
||||||
SearchAttribute
|
|
||||||
} from '@/types/algoliaTypes'
|
|
||||||
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
|
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
|
||||||
|
import type { components } from '@/types/comfyRegistryTypes'
|
||||||
|
import type { QuerySuggestion, SearchMode } from '@/types/searchServiceTypes'
|
||||||
|
|
||||||
|
type RegistryNodePack = components['schemas']['Node']
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_TIME = 320
|
const SEARCH_DEBOUNCE_TIME = 320
|
||||||
const DEFAULT_PAGE_SIZE = 64
|
|
||||||
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
|
const DEFAULT_SORT_FIELD = SortableAlgoliaField.Downloads // Set in the index configuration
|
||||||
const SORT_DIRECTIONS: Record<SortableAlgoliaField, 'asc' | 'desc'> = {
|
|
||||||
[SortableAlgoliaField.Downloads]: 'desc',
|
|
||||||
[SortableAlgoliaField.Created]: 'desc',
|
|
||||||
[SortableAlgoliaField.Updated]: 'desc',
|
|
||||||
[SortableAlgoliaField.Publisher]: 'asc',
|
|
||||||
[SortableAlgoliaField.Name]: 'asc'
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDateField = (field: SortableAlgoliaField): boolean =>
|
|
||||||
field === SortableAlgoliaField.Created ||
|
|
||||||
field === SortableAlgoliaField.Updated
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for managing UI state of Comfy Node Registry search.
|
* Composable for managing UI state of Comfy Node Registry search.
|
||||||
*/
|
*/
|
||||||
export function useRegistrySearch(options: {
|
export function useRegistrySearch(
|
||||||
initialSortField?: SortableAlgoliaField
|
options: {
|
||||||
initialSearchMode?: 'nodes' | 'packs'
|
initialSortField?: string
|
||||||
initialSearchQuery?: string
|
initialSearchMode?: SearchMode
|
||||||
initialPageNumber?: number
|
initialSearchQuery?: string
|
||||||
}) {
|
initialPageNumber?: number
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
const {
|
const {
|
||||||
initialSortField = SortableAlgoliaField.Downloads,
|
initialSortField = DEFAULT_SORT_FIELD,
|
||||||
initialSearchMode = 'packs',
|
initialSearchMode = 'packs',
|
||||||
initialSearchQuery = '',
|
initialSearchQuery = '',
|
||||||
initialPageNumber = 0
|
initialPageNumber = 0
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const sortField = ref<SortableAlgoliaField>(initialSortField)
|
const sortField = ref<string>(initialSortField)
|
||||||
const searchMode = ref<'nodes' | 'packs'>(initialSearchMode)
|
const searchMode = ref<SearchMode>(initialSearchMode)
|
||||||
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
||||||
const pageNumber = ref(initialPageNumber)
|
const pageNumber = ref(initialPageNumber)
|
||||||
const searchQuery = ref(initialSearchQuery)
|
const searchQuery = ref(initialSearchQuery)
|
||||||
const results = ref<AlgoliaNodePack[]>([])
|
const searchResults = ref<RegistryNodePack[]>([])
|
||||||
const suggestions = ref<NodesIndexSuggestion[]>([])
|
const suggestions = ref<QuerySuggestion[]>([])
|
||||||
|
|
||||||
const searchAttributes = computed<SearchAttribute[]>(() =>
|
const searchAttributes = computed<SearchAttribute[]>(() =>
|
||||||
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
|
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
|
||||||
)
|
)
|
||||||
|
|
||||||
const resultsAsRegistryPacks = computed(() =>
|
const searchGateway = useRegistrySearchGateway()
|
||||||
results.value ? results.value.map(algoliaToRegistry) : []
|
|
||||||
)
|
|
||||||
const resultsAsNodes = computed(() =>
|
|
||||||
results.value
|
|
||||||
? results.value.reduce(
|
|
||||||
(acc, hit) => acc.concat(hit.comfy_nodes),
|
|
||||||
[] as string[]
|
|
||||||
)
|
|
||||||
: []
|
|
||||||
)
|
|
||||||
|
|
||||||
const { searchPacksCached, toRegistryPack, clearSearchPacksCache } =
|
const { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
|
||||||
useAlgoliaSearchService()
|
searchGateway
|
||||||
|
|
||||||
const algoliaToRegistry = memoize(
|
|
||||||
toRegistryPack,
|
|
||||||
(algoliaNode: AlgoliaNodePack) => algoliaNode.id
|
|
||||||
)
|
|
||||||
const getSortValue = (pack: Hit<AlgoliaNodePack>) => {
|
|
||||||
if (isDateField(sortField.value)) {
|
|
||||||
const value = pack[sortField.value]
|
|
||||||
return value ? new Date(value).getTime() : 0
|
|
||||||
} else {
|
|
||||||
const value = pack[sortField.value]
|
|
||||||
return value ?? 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSearchResults = async (options: { append?: boolean }) => {
|
const updateSearchResults = async (options: { append?: boolean }) => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
if (!options.append) {
|
if (!options.append) {
|
||||||
pageNumber.value = 0
|
pageNumber.value = 0
|
||||||
}
|
}
|
||||||
const { nodePacks, querySuggestions } = await searchPacksCached(
|
const { nodePacks, querySuggestions } = await searchPacks(
|
||||||
searchQuery.value,
|
searchQuery.value,
|
||||||
{
|
{
|
||||||
pageSize: pageSize.value,
|
pageSize: pageSize.value,
|
||||||
@@ -102,17 +68,22 @@ export function useRegistrySearch(options: {
|
|||||||
|
|
||||||
// Results are sorted by the default field to begin with -- so don't manually sort again
|
// Results are sorted by the default field to begin with -- so don't manually sort again
|
||||||
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
|
if (sortField.value && sortField.value !== DEFAULT_SORT_FIELD) {
|
||||||
|
// Get the sort direction from the provider's sortable fields
|
||||||
|
const sortableFields = getSortableFields()
|
||||||
|
const fieldConfig = sortableFields.find((f) => f.id === sortField.value)
|
||||||
|
const direction = fieldConfig?.direction || 'desc'
|
||||||
|
|
||||||
sortedPacks = orderBy(
|
sortedPacks = orderBy(
|
||||||
nodePacks,
|
nodePacks,
|
||||||
[getSortValue],
|
[(pack) => getSortValue(pack, sortField.value)],
|
||||||
[SORT_DIRECTIONS[sortField.value]]
|
[direction]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.append && results.value?.length) {
|
if (options.append && searchResults.value?.length) {
|
||||||
results.value = results.value.concat(sortedPacks)
|
searchResults.value = searchResults.value.concat(sortedPacks)
|
||||||
} else {
|
} else {
|
||||||
results.value = sortedPacks
|
searchResults.value = sortedPacks
|
||||||
}
|
}
|
||||||
suggestions.value = querySuggestions
|
suggestions.value = querySuggestions
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
@@ -128,6 +99,10 @@ export function useRegistrySearch(options: {
|
|||||||
immediate: true
|
immediate: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const sortOptions = computed(() => {
|
||||||
|
return getSortableFields()
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading,
|
isLoading,
|
||||||
pageNumber,
|
pageNumber,
|
||||||
@@ -136,8 +111,8 @@ export function useRegistrySearch(options: {
|
|||||||
searchMode,
|
searchMode,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
suggestions,
|
suggestions,
|
||||||
searchResults: resultsAsRegistryPacks,
|
searchResults,
|
||||||
nodeSearchResults: resultsAsNodes,
|
sortOptions,
|
||||||
clearCache: clearSearchPacksCache
|
clearCache: clearSearchCache
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/constants/searchConstants.ts
Normal file
3
src/constants/searchConstants.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const SEARCH_CACHE_MAX_SIZE = 64
|
||||||
|
export const DEFAULT_PAGE_SIZE = 64
|
||||||
|
export const MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA = 2
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
import QuickLRU from '@alloc/quick-lru'
|
|
||||||
import type {
|
|
||||||
SearchQuery,
|
|
||||||
SearchResponse
|
|
||||||
} from 'algoliasearch/dist/lite/browser'
|
|
||||||
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
|
|
||||||
import { omit } from 'lodash'
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AlgoliaNodePack,
|
|
||||||
NodesIndexSuggestion,
|
|
||||||
SearchAttribute,
|
|
||||||
SearchNodePacksParams,
|
|
||||||
SearchPacksResult
|
|
||||||
} from '@/types/algoliaTypes'
|
|
||||||
import type { components } from '@/types/comfyRegistryTypes'
|
|
||||||
import { paramsToCacheKey } from '@/utils/formatUtil'
|
|
||||||
|
|
||||||
type RegistryNodePack = components['schemas']['Node']
|
|
||||||
|
|
||||||
const DEFAULT_MAX_CACHE_SIZE = 64
|
|
||||||
const DEFAULT_MIN_CHARS_FOR_SUGGESTIONS = 2
|
|
||||||
|
|
||||||
const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
|
|
||||||
'comfy_nodes',
|
|
||||||
'name',
|
|
||||||
'description',
|
|
||||||
'latest_version',
|
|
||||||
'status',
|
|
||||||
'publisher_id',
|
|
||||||
'total_install',
|
|
||||||
'create_time',
|
|
||||||
'update_time',
|
|
||||||
'license',
|
|
||||||
'repository_url',
|
|
||||||
'latest_version_status',
|
|
||||||
'comfy_node_extract_status',
|
|
||||||
'id',
|
|
||||||
'icon_url'
|
|
||||||
]
|
|
||||||
|
|
||||||
interface AlgoliaSearchServiceOptions {
|
|
||||||
/**
|
|
||||||
* Minimum number of characters for suggestions. An additional query
|
|
||||||
* will be made to the suggestions/completions index for queries that
|
|
||||||
* are this length or longer.
|
|
||||||
* @default 3
|
|
||||||
*/
|
|
||||||
minCharsForSuggestions?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
|
|
||||||
maxSize: DEFAULT_MAX_CACHE_SIZE
|
|
||||||
})
|
|
||||||
|
|
||||||
export const useAlgoliaSearchService = (
|
|
||||||
options: AlgoliaSearchServiceOptions = {}
|
|
||||||
) => {
|
|
||||||
const { minCharsForSuggestions = DEFAULT_MIN_CHARS_FOR_SUGGESTIONS } = options
|
|
||||||
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
|
|
||||||
|
|
||||||
const toRegistryLatestVersion = (
|
|
||||||
algoliaNode: AlgoliaNodePack
|
|
||||||
): RegistryNodePack['latest_version'] => {
|
|
||||||
return {
|
|
||||||
version: algoliaNode.latest_version,
|
|
||||||
createdAt: algoliaNode.update_time,
|
|
||||||
status: algoliaNode.latest_version_status,
|
|
||||||
comfy_node_extract_status:
|
|
||||||
algoliaNode.comfy_node_extract_status ?? undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toRegistryPublisher = (
|
|
||||||
algoliaNode: AlgoliaNodePack
|
|
||||||
): RegistryNodePack['publisher'] => {
|
|
||||||
return {
|
|
||||||
id: algoliaNode.publisher_id,
|
|
||||||
name: algoliaNode.publisher_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert from node pack in Algolia format to Comfy Registry format
|
|
||||||
*/
|
|
||||||
function toRegistryPack(algoliaNode: AlgoliaNodePack): RegistryNodePack {
|
|
||||||
return {
|
|
||||||
id: algoliaNode.id ?? algoliaNode.objectID,
|
|
||||||
name: algoliaNode.name,
|
|
||||||
description: algoliaNode.description,
|
|
||||||
repository: algoliaNode.repository_url,
|
|
||||||
license: algoliaNode.license,
|
|
||||||
downloads: algoliaNode.total_install,
|
|
||||||
status: algoliaNode.status,
|
|
||||||
icon: algoliaNode.icon_url,
|
|
||||||
latest_version: toRegistryLatestVersion(algoliaNode),
|
|
||||||
publisher: toRegistryPublisher(algoliaNode),
|
|
||||||
// @ts-expect-error remove when comfy_nodes is added to node (pack) info
|
|
||||||
comfy_nodes: algoliaNode.comfy_nodes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for node packs in Algolia
|
|
||||||
*/
|
|
||||||
const searchPacks = async (
|
|
||||||
query: string,
|
|
||||||
params: SearchNodePacksParams
|
|
||||||
): Promise<SearchPacksResult> => {
|
|
||||||
const { pageSize, pageNumber } = params
|
|
||||||
const rest = omit(params, ['pageSize', 'pageNumber'])
|
|
||||||
|
|
||||||
const requests: SearchQuery[] = [
|
|
||||||
{
|
|
||||||
query,
|
|
||||||
indexName: 'nodes_index',
|
|
||||||
attributesToRetrieve: RETRIEVE_ATTRIBUTES,
|
|
||||||
...rest,
|
|
||||||
hitsPerPage: pageSize,
|
|
||||||
page: pageNumber
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const shouldQuerySuggestions = query.length >= minCharsForSuggestions
|
|
||||||
|
|
||||||
// If the query is long enough, also query the suggestions index
|
|
||||||
if (shouldQuerySuggestions) {
|
|
||||||
requests.push({
|
|
||||||
indexName: 'nodes_index_query_suggestions',
|
|
||||||
query
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const { results } = await searchClient.search<
|
|
||||||
AlgoliaNodePack | NodesIndexSuggestion
|
|
||||||
>({
|
|
||||||
requests,
|
|
||||||
strategy: 'none'
|
|
||||||
})
|
|
||||||
|
|
||||||
const [nodePacks, querySuggestions = { hits: [] }] = results as [
|
|
||||||
SearchResponse<AlgoliaNodePack>,
|
|
||||||
SearchResponse<NodesIndexSuggestion>
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodePacks: nodePacks.hits,
|
|
||||||
querySuggestions: querySuggestions.hits
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchPacksCached = async (
|
|
||||||
query: string,
|
|
||||||
params: SearchNodePacksParams
|
|
||||||
): Promise<SearchPacksResult> => {
|
|
||||||
const cacheKey = paramsToCacheKey({ query, ...params })
|
|
||||||
const cachedResult = searchPacksCache.get(cacheKey)
|
|
||||||
if (cachedResult !== undefined) return cachedResult
|
|
||||||
|
|
||||||
const result = await searchPacks(query, params)
|
|
||||||
searchPacksCache.set(cacheKey, result)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearSearchPacksCache = () => {
|
|
||||||
searchPacksCache.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
searchPacks,
|
|
||||||
searchPacksCached,
|
|
||||||
toRegistryPack,
|
|
||||||
clearSearchPacksCache
|
|
||||||
}
|
|
||||||
}
|
|
||||||
227
src/services/gateway/registrySearchGateway.ts
Normal file
227
src/services/gateway/registrySearchGateway.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
|
||||||
|
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
|
||||||
|
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
|
||||||
|
import type { components } from '@/types/comfyRegistryTypes'
|
||||||
|
import type {
|
||||||
|
NodePackSearchProvider,
|
||||||
|
SearchPacksResult
|
||||||
|
} from '@/types/searchServiceTypes'
|
||||||
|
|
||||||
|
type RegistryNodePack = components['schemas']['Node']
|
||||||
|
|
||||||
|
interface ProviderState {
|
||||||
|
provider: NodePackSearchProvider
|
||||||
|
name: string
|
||||||
|
isHealthy: boolean
|
||||||
|
lastError?: Error
|
||||||
|
lastAttempt?: Date
|
||||||
|
consecutiveFailures: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const CIRCUIT_BREAKER_THRESHOLD = 3 // Number of failures before circuit opens
|
||||||
|
const CIRCUIT_BREAKER_TIMEOUT = 60000 // 1 minute before retry
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Gateway for registry search providers with circuit breaker pattern.
|
||||||
|
* Acts as a single entry point that routes search requests to appropriate providers
|
||||||
|
* and handles failures gracefully by falling back to alternative providers.
|
||||||
|
*
|
||||||
|
* Implements:
|
||||||
|
* - Gateway pattern: Single entry point for all search requests
|
||||||
|
* - Circuit breaker: Prevents repeated calls to failed services
|
||||||
|
* - Automatic failover: Cascades through providers on failure
|
||||||
|
*/
|
||||||
|
export const useRegistrySearchGateway = (): NodePackSearchProvider => {
|
||||||
|
const providers: ProviderState[] = []
|
||||||
|
let activeProviderIndex = 0
|
||||||
|
|
||||||
|
// Initialize providers in priority order
|
||||||
|
try {
|
||||||
|
providers.push({
|
||||||
|
provider: useAlgoliaSearchProvider(),
|
||||||
|
name: 'Algolia',
|
||||||
|
isHealthy: true,
|
||||||
|
consecutiveFailures: 0
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to initialize Algolia provider:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
providers.push({
|
||||||
|
provider: useComfyRegistrySearchProvider(),
|
||||||
|
name: 'ComfyRegistry',
|
||||||
|
isHealthy: true,
|
||||||
|
consecutiveFailures: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: Add an "offline" provider that operates on a local cache of the registry.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a provider's circuit breaker should be closed (available to try)
|
||||||
|
*/
|
||||||
|
const isCircuitClosed = (providerState: ProviderState): boolean => {
|
||||||
|
if (providerState.consecutiveFailures < CIRCUIT_BREAKER_THRESHOLD) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if enough time has passed to retry
|
||||||
|
if (providerState.lastAttempt) {
|
||||||
|
const timeSinceLastAttempt =
|
||||||
|
Date.now() - providerState.lastAttempt.getTime()
|
||||||
|
if (timeSinceLastAttempt > CIRCUIT_BREAKER_TIMEOUT) {
|
||||||
|
console.info(
|
||||||
|
`Retrying ${providerState.name} provider after circuit breaker timeout`
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a successful call to a provider
|
||||||
|
*/
|
||||||
|
const recordSuccess = (providerState: ProviderState) => {
|
||||||
|
providerState.isHealthy = true
|
||||||
|
providerState.consecutiveFailures = 0
|
||||||
|
providerState.lastError = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a failed call to a provider
|
||||||
|
*/
|
||||||
|
const recordFailure = (providerState: ProviderState, error: Error) => {
|
||||||
|
providerState.consecutiveFailures++
|
||||||
|
providerState.lastError = error
|
||||||
|
providerState.lastAttempt = new Date()
|
||||||
|
|
||||||
|
if (providerState.consecutiveFailures >= CIRCUIT_BREAKER_THRESHOLD) {
|
||||||
|
providerState.isHealthy = false
|
||||||
|
console.warn(
|
||||||
|
`${providerState.name} provider circuit breaker opened after ${providerState.consecutiveFailures} failures`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the currently active provider based on circuit breaker states
|
||||||
|
*/
|
||||||
|
const getActiveProvider = (): NodePackSearchProvider => {
|
||||||
|
// First, try to use the current active provider if it's healthy
|
||||||
|
const currentProvider = providers[activeProviderIndex]
|
||||||
|
if (currentProvider && isCircuitClosed(currentProvider)) {
|
||||||
|
return currentProvider.provider
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, find the first healthy provider
|
||||||
|
for (let i = 0; i < providers.length; i++) {
|
||||||
|
const providerState = providers[i]
|
||||||
|
if (isCircuitClosed(providerState)) {
|
||||||
|
activeProviderIndex = i
|
||||||
|
return providerState.provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No available search providers')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the active provider index after a failure.
|
||||||
|
* Move to the next provider if available.
|
||||||
|
*/
|
||||||
|
const updateActiveProviderOnFailure = () => {
|
||||||
|
if (activeProviderIndex < providers.length - 1) {
|
||||||
|
activeProviderIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for node packs.
|
||||||
|
*/
|
||||||
|
const searchPacks = async (
|
||||||
|
query: string,
|
||||||
|
params: SearchNodePacksParams
|
||||||
|
): Promise<SearchPacksResult> => {
|
||||||
|
let lastError: Error | null = null
|
||||||
|
|
||||||
|
// Start with the current active provider
|
||||||
|
for (let attempts = 0; attempts < providers.length; attempts++) {
|
||||||
|
try {
|
||||||
|
const provider = getActiveProvider()
|
||||||
|
const providerState = providers[activeProviderIndex]
|
||||||
|
|
||||||
|
const result = await provider.searchPacks(query, params)
|
||||||
|
recordSuccess(providerState)
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error
|
||||||
|
const providerState = providers[activeProviderIndex]
|
||||||
|
recordFailure(providerState, lastError)
|
||||||
|
console.warn(
|
||||||
|
`${providerState.name} search provider failed (${providerState.consecutiveFailures} failures):`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try the next provider
|
||||||
|
updateActiveProviderOnFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, all providers failed
|
||||||
|
throw new Error(
|
||||||
|
`All search providers failed. Last error: ${lastError?.message || 'Unknown error'}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the search cache for all providers that implement it.
|
||||||
|
*/
|
||||||
|
const clearSearchCache = () => {
|
||||||
|
for (const providerState of providers) {
|
||||||
|
try {
|
||||||
|
providerState.provider.clearSearchCache()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to clear cache for ${providerState.name} provider:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the sort value for a pack.
|
||||||
|
* @example
|
||||||
|
* const pack = {
|
||||||
|
* id: '123',
|
||||||
|
* name: 'Test Pack',
|
||||||
|
* downloads: 100
|
||||||
|
* }
|
||||||
|
* const sortValue = getSortValue(pack, 'downloads')
|
||||||
|
* console.log(sortValue) // 100
|
||||||
|
*/
|
||||||
|
const getSortValue = (
|
||||||
|
pack: RegistryNodePack,
|
||||||
|
sortField: string
|
||||||
|
): string | number => {
|
||||||
|
return getActiveProvider().getSortValue(pack, sortField)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the sortable fields for the active provider.
|
||||||
|
* @example
|
||||||
|
* const sortableFields = getSortableFields()
|
||||||
|
* console.log(sortableFields) // ['downloads', 'created', 'updated', 'publisher', 'name']
|
||||||
|
*/
|
||||||
|
const getSortableFields = () => {
|
||||||
|
return getActiveProvider().getSortableFields()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchPacks,
|
||||||
|
clearSearchCache,
|
||||||
|
getSortValue,
|
||||||
|
getSortableFields
|
||||||
|
}
|
||||||
|
}
|
||||||
232
src/services/providers/algoliaSearchProvider.ts
Normal file
232
src/services/providers/algoliaSearchProvider.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import QuickLRU from '@alloc/quick-lru'
|
||||||
|
import type {
|
||||||
|
SearchQuery,
|
||||||
|
SearchResponse
|
||||||
|
} from 'algoliasearch/dist/lite/browser'
|
||||||
|
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
|
||||||
|
import { memoize, omit } from 'lodash'
|
||||||
|
|
||||||
|
import {
|
||||||
|
MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA,
|
||||||
|
SEARCH_CACHE_MAX_SIZE
|
||||||
|
} from '@/constants/searchConstants'
|
||||||
|
import type {
|
||||||
|
AlgoliaNodePack,
|
||||||
|
NodesIndexSuggestion,
|
||||||
|
SearchAttribute,
|
||||||
|
SearchNodePacksParams
|
||||||
|
} from '@/types/algoliaTypes'
|
||||||
|
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
|
||||||
|
import type { components } from '@/types/comfyRegistryTypes'
|
||||||
|
import type {
|
||||||
|
NodePackSearchProvider,
|
||||||
|
SearchPacksResult,
|
||||||
|
SortableField
|
||||||
|
} from '@/types/searchServiceTypes'
|
||||||
|
import { paramsToCacheKey } from '@/utils/formatUtil'
|
||||||
|
|
||||||
|
type RegistryNodePack = components['schemas']['Node']
|
||||||
|
|
||||||
|
const RETRIEVE_ATTRIBUTES: SearchAttribute[] = [
|
||||||
|
'comfy_nodes',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'latest_version',
|
||||||
|
'status',
|
||||||
|
'publisher_id',
|
||||||
|
'total_install',
|
||||||
|
'create_time',
|
||||||
|
'update_time',
|
||||||
|
'license',
|
||||||
|
'repository_url',
|
||||||
|
'latest_version_status',
|
||||||
|
'comfy_node_extract_status',
|
||||||
|
'id',
|
||||||
|
'icon_url'
|
||||||
|
]
|
||||||
|
|
||||||
|
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
|
||||||
|
maxSize: SEARCH_CACHE_MAX_SIZE
|
||||||
|
})
|
||||||
|
|
||||||
|
const toRegistryLatestVersion = (
|
||||||
|
algoliaNode: AlgoliaNodePack
|
||||||
|
): RegistryNodePack['latest_version'] => {
|
||||||
|
return {
|
||||||
|
version: algoliaNode.latest_version,
|
||||||
|
createdAt: algoliaNode.update_time,
|
||||||
|
status: algoliaNode.latest_version_status,
|
||||||
|
comfy_node_extract_status:
|
||||||
|
algoliaNode.comfy_node_extract_status ?? undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toRegistryPublisher = (
|
||||||
|
algoliaNode: AlgoliaNodePack
|
||||||
|
): RegistryNodePack['publisher'] => {
|
||||||
|
return {
|
||||||
|
id: algoliaNode.publisher_id,
|
||||||
|
name: algoliaNode.publisher_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert from node pack in Algolia format to Comfy Registry format
|
||||||
|
*/
|
||||||
|
const toRegistryPack = memoize(
|
||||||
|
(algoliaNode: AlgoliaNodePack): RegistryNodePack => {
|
||||||
|
return {
|
||||||
|
id: algoliaNode.id ?? algoliaNode.objectID,
|
||||||
|
name: algoliaNode.name,
|
||||||
|
description: algoliaNode.description,
|
||||||
|
repository: algoliaNode.repository_url,
|
||||||
|
license: algoliaNode.license,
|
||||||
|
downloads: algoliaNode.total_install,
|
||||||
|
status: algoliaNode.status,
|
||||||
|
icon: algoliaNode.icon_url,
|
||||||
|
latest_version: toRegistryLatestVersion(algoliaNode),
|
||||||
|
publisher: toRegistryPublisher(algoliaNode),
|
||||||
|
// @ts-expect-error comfy_nodes also not in node info
|
||||||
|
comfy_nodes: algoliaNode.comfy_nodes,
|
||||||
|
create_time: algoliaNode.create_time
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(algoliaNode: AlgoliaNodePack) => algoliaNode.id
|
||||||
|
)
|
||||||
|
|
||||||
|
export const useAlgoliaSearchProvider = (): NodePackSearchProvider => {
|
||||||
|
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for node packs in Algolia (internal method)
|
||||||
|
*/
|
||||||
|
const searchPacksInternal = async (
|
||||||
|
query: string,
|
||||||
|
params: SearchNodePacksParams
|
||||||
|
): Promise<SearchPacksResult> => {
|
||||||
|
const { pageSize, pageNumber } = params
|
||||||
|
const rest = omit(params, ['pageSize', 'pageNumber'])
|
||||||
|
|
||||||
|
const requests: SearchQuery[] = [
|
||||||
|
{
|
||||||
|
query,
|
||||||
|
indexName: 'nodes_index',
|
||||||
|
attributesToRetrieve: RETRIEVE_ATTRIBUTES,
|
||||||
|
...rest,
|
||||||
|
hitsPerPage: pageSize,
|
||||||
|
page: pageNumber
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const shouldQuerySuggestions =
|
||||||
|
query.length >= MIN_CHARS_FOR_SUGGESTIONS_ALGOLIA
|
||||||
|
|
||||||
|
// If the query is long enough, also query the suggestions index
|
||||||
|
if (shouldQuerySuggestions) {
|
||||||
|
requests.push({
|
||||||
|
indexName: 'nodes_index_query_suggestions',
|
||||||
|
query
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = await searchClient.search<
|
||||||
|
AlgoliaNodePack | NodesIndexSuggestion
|
||||||
|
>({
|
||||||
|
requests,
|
||||||
|
strategy: 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
const [nodePacks, querySuggestions = { hits: [] }] = results as [
|
||||||
|
SearchResponse<AlgoliaNodePack>,
|
||||||
|
SearchResponse<NodesIndexSuggestion>
|
||||||
|
]
|
||||||
|
|
||||||
|
// Convert Algolia hits to RegistryNodePack format
|
||||||
|
const registryPacks = nodePacks.hits.map(toRegistryPack)
|
||||||
|
|
||||||
|
// Extract query suggestions from search results
|
||||||
|
const suggestions = querySuggestions.hits.map((suggestion) => ({
|
||||||
|
query: suggestion.query,
|
||||||
|
popularity: suggestion.popularity
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodePacks: registryPacks,
|
||||||
|
querySuggestions: suggestions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for node packs in Algolia with caching.
|
||||||
|
*/
|
||||||
|
const searchPacks = async (
|
||||||
|
query: string,
|
||||||
|
params: SearchNodePacksParams
|
||||||
|
): Promise<SearchPacksResult> => {
|
||||||
|
const cacheKey = paramsToCacheKey({ query, ...params })
|
||||||
|
const cachedResult = searchPacksCache.get(cacheKey)
|
||||||
|
if (cachedResult !== undefined) return cachedResult
|
||||||
|
|
||||||
|
const result = await searchPacksInternal(query, params)
|
||||||
|
searchPacksCache.set(cacheKey, result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearchCache = () => {
|
||||||
|
searchPacksCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSortValue = (
|
||||||
|
pack: RegistryNodePack,
|
||||||
|
sortField: string
|
||||||
|
): string | number => {
|
||||||
|
// For Algolia, we rely on the default sorting behavior
|
||||||
|
// The results are already sorted by the index configuration
|
||||||
|
// This is mainly used for re-sorting after results are fetched
|
||||||
|
switch (sortField) {
|
||||||
|
case SortableAlgoliaField.Downloads:
|
||||||
|
return pack.downloads ?? 0
|
||||||
|
case SortableAlgoliaField.Created: {
|
||||||
|
// TODO: add create time to backend return type
|
||||||
|
// @ts-expect-error create_time is not in the RegistryNodePack type
|
||||||
|
const createTime = pack.create_time
|
||||||
|
return createTime ? new Date(createTime).getTime() : 0
|
||||||
|
}
|
||||||
|
case SortableAlgoliaField.Updated:
|
||||||
|
return pack.latest_version?.createdAt
|
||||||
|
? new Date(pack.latest_version.createdAt).getTime()
|
||||||
|
: 0
|
||||||
|
case SortableAlgoliaField.Publisher:
|
||||||
|
return pack.publisher?.name ?? ''
|
||||||
|
case SortableAlgoliaField.Name:
|
||||||
|
return pack.name ?? ''
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSortableFields = (): SortableField[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: SortableAlgoliaField.Downloads,
|
||||||
|
label: 'Downloads',
|
||||||
|
direction: 'desc'
|
||||||
|
},
|
||||||
|
{ id: SortableAlgoliaField.Created, label: 'Created', direction: 'desc' },
|
||||||
|
{ id: SortableAlgoliaField.Updated, label: 'Updated', direction: 'desc' },
|
||||||
|
{
|
||||||
|
id: SortableAlgoliaField.Publisher,
|
||||||
|
label: 'Publisher',
|
||||||
|
direction: 'asc'
|
||||||
|
},
|
||||||
|
{ id: SortableAlgoliaField.Name, label: 'Name', direction: 'asc' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchPacks,
|
||||||
|
clearSearchCache,
|
||||||
|
getSortValue,
|
||||||
|
getSortableFields
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/services/providers/registrySearchProvider.ts
Normal file
92
src/services/providers/registrySearchProvider.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||||
|
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
|
||||||
|
import type { components } from '@/types/comfyRegistryTypes'
|
||||||
|
import type {
|
||||||
|
NodePackSearchProvider,
|
||||||
|
SearchPacksResult,
|
||||||
|
SortableField
|
||||||
|
} from '@/types/searchServiceTypes'
|
||||||
|
|
||||||
|
type RegistryNodePack = components['schemas']['Node']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search provider for the Comfy Registry.
|
||||||
|
* Uses public Comfy Registry API.
|
||||||
|
*/
|
||||||
|
export const useComfyRegistrySearchProvider = (): NodePackSearchProvider => {
|
||||||
|
const registryStore = useComfyRegistryStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for node packs using the Comfy Registry API.
|
||||||
|
*/
|
||||||
|
const searchPacks = async (
|
||||||
|
query: string,
|
||||||
|
params: SearchNodePacksParams
|
||||||
|
): Promise<SearchPacksResult> => {
|
||||||
|
const { pageSize, pageNumber, restrictSearchableAttributes } = params
|
||||||
|
|
||||||
|
// Determine search mode based on searchable attributes
|
||||||
|
const isNodeSearch = restrictSearchableAttributes?.includes('comfy_nodes')
|
||||||
|
|
||||||
|
const searchParams = {
|
||||||
|
search: isNodeSearch ? undefined : query,
|
||||||
|
comfy_node_search: isNodeSearch ? query : undefined,
|
||||||
|
limit: pageSize,
|
||||||
|
offset: pageNumber * pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchResult = await registryStore.search.call(searchParams)
|
||||||
|
|
||||||
|
if (!searchResult || !searchResult.nodes) {
|
||||||
|
return {
|
||||||
|
nodePacks: [],
|
||||||
|
querySuggestions: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodePacks: searchResult.nodes,
|
||||||
|
querySuggestions: [] // Registry doesn't support query suggestions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearchCache = () => {
|
||||||
|
registryStore.search.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSortValue = (
|
||||||
|
pack: RegistryNodePack,
|
||||||
|
sortField: string
|
||||||
|
): string | number => {
|
||||||
|
switch (sortField) {
|
||||||
|
case 'downloads':
|
||||||
|
return pack.downloads ?? 0
|
||||||
|
case 'name':
|
||||||
|
return pack.name ?? ''
|
||||||
|
case 'publisher':
|
||||||
|
return pack.publisher?.name ?? ''
|
||||||
|
case 'updated':
|
||||||
|
return pack.latest_version?.createdAt
|
||||||
|
? new Date(pack.latest_version.createdAt).getTime()
|
||||||
|
: 0
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSortableFields = (): SortableField[] => {
|
||||||
|
return [
|
||||||
|
{ id: 'downloads', label: 'Downloads', direction: 'desc' },
|
||||||
|
{ id: 'name', label: 'Name', direction: 'asc' },
|
||||||
|
{ id: 'publisher', label: 'Publisher', direction: 'asc' },
|
||||||
|
{ id: 'updated', label: 'Updated', direction: 'desc' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchPacks,
|
||||||
|
clearSearchCache,
|
||||||
|
getSortValue,
|
||||||
|
getSortableFields
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,20 @@ type SafeNestedProperty<
|
|||||||
> = T[K1] extends undefined | null ? undefined : NonNullable<T[K1]>[K2]
|
> = T[K1] extends undefined | null ? undefined : NonNullable<T[K1]>[K2]
|
||||||
|
|
||||||
type RegistryNodePack = components['schemas']['Node']
|
type RegistryNodePack = components['schemas']['Node']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of searching the Algolia index.
|
||||||
|
* Represents the entire result of a search query.
|
||||||
|
*/
|
||||||
export type SearchPacksResult = {
|
export type SearchPacksResult = {
|
||||||
nodePacks: Hit<AlgoliaNodePack>[]
|
nodePacks: Hit<AlgoliaNodePack>[]
|
||||||
querySuggestions: Hit<NodesIndexSuggestion>[]
|
querySuggestions: Hit<NodesIndexSuggestion>[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node pack record after it has been mapped to Algolia index format.
|
||||||
|
* @see https://github.com/Comfy-Org/comfy-api/blob/main/mapper/algolia.go
|
||||||
|
*/
|
||||||
export interface AlgoliaNodePack {
|
export interface AlgoliaNodePack {
|
||||||
objectID: RegistryNodePack['id']
|
objectID: RegistryNodePack['id']
|
||||||
name: RegistryNodePack['name']
|
name: RegistryNodePack['name']
|
||||||
@@ -52,7 +61,14 @@ export interface AlgoliaNodePack {
|
|||||||
icon_url: RegistryNodePack['icon']
|
icon_url: RegistryNodePack['icon']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An attribute that can be used to search the Algolia index by.
|
||||||
|
*/
|
||||||
export type SearchAttribute = keyof AlgoliaNodePack
|
export type SearchAttribute = keyof AlgoliaNodePack
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggestion for a search query (autocomplete).
|
||||||
|
*/
|
||||||
export interface NodesIndexSuggestion {
|
export interface NodesIndexSuggestion {
|
||||||
nb_words: number
|
nb_words: number
|
||||||
nodes_index: {
|
nodes_index: {
|
||||||
@@ -67,8 +83,11 @@ export interface NodesIndexSuggestion {
|
|||||||
query: string
|
query: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters for searching the Algolia index.
|
||||||
|
*/
|
||||||
export type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
|
export type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
|
||||||
pageSize: number
|
pageSize: number
|
||||||
pageNumber: number
|
pageNumber: number
|
||||||
restrictSearchableAttributes: SearchAttribute[]
|
restrictSearchableAttributes?: SearchAttribute[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { InjectionKey, Ref } from 'vue'
|
|||||||
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||||
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
|
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
|
||||||
import type { components } from '@/types/comfyRegistryTypes'
|
import type { components } from '@/types/comfyRegistryTypes'
|
||||||
|
import type { SearchMode } from '@/types/searchServiceTypes'
|
||||||
|
|
||||||
type WorkflowNodeProperties = ComfyWorkflowJSON['nodes'][0]['properties']
|
type WorkflowNodeProperties = ComfyWorkflowJSON['nodes'][0]['properties']
|
||||||
|
|
||||||
@@ -238,6 +239,6 @@ export interface UpdateAllPacksParams {
|
|||||||
export interface ManagerState {
|
export interface ManagerState {
|
||||||
selectedTabId: ManagerTab
|
selectedTabId: ManagerTab
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
searchMode: 'nodes' | 'packs'
|
searchMode: SearchMode
|
||||||
sortField: SortableAlgoliaField
|
sortField: string
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/types/searchServiceTypes.ts
Normal file
49
src/types/searchServiceTypes.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { SearchNodePacksParams } from '@/types/algoliaTypes'
|
||||||
|
import type { components } from '@/types/comfyRegistryTypes'
|
||||||
|
|
||||||
|
type RegistryNodePack = components['schemas']['Node']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search mode for filtering results
|
||||||
|
*/
|
||||||
|
export type SearchMode = 'nodes' | 'packs'
|
||||||
|
export type QuerySuggestion = {
|
||||||
|
query: string
|
||||||
|
popularity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchPacksResult {
|
||||||
|
nodePacks: RegistryNodePack[]
|
||||||
|
querySuggestions: QuerySuggestion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortableField<T = string> {
|
||||||
|
id: T
|
||||||
|
label: string
|
||||||
|
direction: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodePackSearchProvider {
|
||||||
|
/**
|
||||||
|
* Search for node packs
|
||||||
|
*/
|
||||||
|
searchPacks(
|
||||||
|
query: string,
|
||||||
|
params: SearchNodePacksParams
|
||||||
|
): Promise<SearchPacksResult>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the search cache
|
||||||
|
*/
|
||||||
|
clearSearchCache(): void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the sort value for a pack based on the sort field
|
||||||
|
*/
|
||||||
|
getSortValue(pack: RegistryNodePack, sortField: string): string | number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the list of sortable fields supported by this provider
|
||||||
|
*/
|
||||||
|
getSortableFields(): SortableField[]
|
||||||
|
}
|
||||||
365
tests-ui/tests/services/algoliaSearchProvider.test.ts
Normal file
365
tests-ui/tests/services/algoliaSearchProvider.test.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
|
||||||
|
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
|
||||||
|
|
||||||
|
// Mock global Algolia constants
|
||||||
|
|
||||||
|
;(global as any).__ALGOLIA_APP_ID__ = 'test-app-id'
|
||||||
|
;(global as any).__ALGOLIA_API_KEY__ = 'test-api-key'
|
||||||
|
|
||||||
|
// Mock algoliasearch
|
||||||
|
vi.mock('algoliasearch/dist/lite/builds/browser', () => ({
|
||||||
|
liteClient: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useAlgoliaSearchProvider', () => {
|
||||||
|
let mockSearchClient: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
// Create mock search client
|
||||||
|
mockSearchClient = {
|
||||||
|
search: vi.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(algoliasearch).mockReturnValue(mockSearchClient)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clear the module-level cache between tests
|
||||||
|
const provider = useAlgoliaSearchProvider()
|
||||||
|
provider.clearSearchCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('searchPacks', () => {
|
||||||
|
it('should search for packs and convert results', async () => {
|
||||||
|
const mockAlgoliaResults = {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
hits: [
|
||||||
|
{
|
||||||
|
objectID: 'algolia-1',
|
||||||
|
id: 'pack-1',
|
||||||
|
name: 'Test Pack',
|
||||||
|
description: 'A test pack',
|
||||||
|
publisher_id: 'publisher-1',
|
||||||
|
total_install: 500,
|
||||||
|
create_time: '2024-01-01T00:00:00Z',
|
||||||
|
update_time: '2024-01-15T00:00:00Z',
|
||||||
|
repository_url: 'https://github.com/test/pack',
|
||||||
|
license: 'MIT',
|
||||||
|
status: 'active',
|
||||||
|
latest_version: '1.0.0',
|
||||||
|
latest_version_status: 'published',
|
||||||
|
icon_url: 'https://example.com/icon.png',
|
||||||
|
comfy_nodes: ['LoadImage', 'SaveImage']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ hits: [] } // Query suggestions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
|
||||||
|
|
||||||
|
const provider = useAlgoliaSearchProvider()
|
||||||
|
const result = await provider.searchPacks('test', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockSearchClient.search).toHaveBeenCalledWith({
|
||||||
|
requests: [
|
||||||
|
{
|
||||||
|
query: 'test',
|
||||||
|
indexName: 'nodes_index',
|
||||||
|
attributesToRetrieve: expect.any(Array),
|
||||||
|
hitsPerPage: 10,
|
||||||
|
page: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
query: 'test',
|
||||||
|
indexName: 'nodes_index_query_suggestions'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
strategy: 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.nodePacks).toHaveLength(1)
|
||||||
|
expect(result.nodePacks[0]).toEqual({
|
||||||
|
id: 'pack-1',
|
||||||
|
name: 'Test Pack',
|
||||||
|
description: 'A test pack',
|
||||||
|
repository: 'https://github.com/test/pack',
|
||||||
|
license: 'MIT',
|
||||||
|
downloads: 500,
|
||||||
|
status: 'active',
|
||||||
|
icon: 'https://example.com/icon.png',
|
||||||
|
latest_version: {
|
||||||
|
version: '1.0.0',
|
||||||
|
createdAt: '2024-01-15T00:00:00Z',
|
||||||
|
status: 'published',
|
||||||
|
comfy_node_extract_status: undefined
|
||||||
|
},
|
||||||
|
publisher: {
|
||||||
|
id: 'publisher-1',
|
||||||
|
name: 'publisher-1'
|
||||||
|
},
|
||||||
|
create_time: '2024-01-01T00:00:00Z',
|
||||||
|
comfy_nodes: ['LoadImage', 'SaveImage']
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include query suggestions when query is long enough', async () => {
|
||||||
|
const mockAlgoliaResults = {
|
||||||
|
results: [
|
||||||
|
{ hits: [] }, // Main results
|
||||||
|
{
|
||||||
|
hits: [
|
||||||
|
{ query: 'test query', popularity: 10 },
|
||||||
|
{ query: 'test pack', popularity: 5 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
|
||||||
|
|
||||||
|
const provider = useAlgoliaSearchProvider()
|
||||||
|
const result = await provider.searchPacks('test', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should make 2 requests (main + suggestions)
|
||||||
|
expect(mockSearchClient.search).toHaveBeenCalledWith({
|
||||||
|
requests: [
|
||||||
|
expect.objectContaining({ indexName: 'nodes_index' }),
|
||||||
|
expect.objectContaining({
|
||||||
|
indexName: 'nodes_index_query_suggestions'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
strategy: 'none'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.querySuggestions).toEqual([
|
||||||
|
{ query: 'test query', popularity: 10 },
|
||||||
|
{ query: 'test pack', popularity: 5 }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not query suggestions for short queries', async () => {
|
||||||
|
mockSearchClient.search.mockResolvedValue({
|
||||||
|
results: [{ hits: [] }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const provider = useAlgoliaSearchProvider()
|
||||||
|
await provider.searchPacks('a', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should only make 1 request (no suggestions)
|
||||||
|
expect(mockSearchClient.search).toHaveBeenCalledWith({
|
||||||
|
requests: [expect.objectContaining({ indexName: 'nodes_index' })],
|
||||||
|
strategy: 'none'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should cache search results', async () => {
|
||||||
|
mockSearchClient.search.mockResolvedValue({
|
||||||
|
results: [{ hits: [] }, { hits: [] }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const provider = useAlgoliaSearchProvider()
|
||||||
|
const params = { pageSize: 10, pageNumber: 0 }
|
||||||
|
|
||||||
|
// First call
|
||||||
|
await provider.searchPacks('test', params)
|
||||||
|
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Second call with same params should use cache
|
||||||
|
await provider.searchPacks('test', params)
|
||||||
|
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Different params should make new request
|
||||||
|
await provider.searchPacks('test', { ...params, pageNumber: 1 })
|
||||||
|
expect(mockSearchClient.search).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing objectID by using id field', async () => {
|
||||||
|
const mockAlgoliaResults = {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
hits: [
|
||||||
|
{
|
||||||
|
id: 'pack-id-only',
|
||||||
|
name: 'Pack without objectID',
|
||||||
|
// ... other required fields
|
||||||
|
publisher_id: 'pub',
|
||||||
|
total_install: 0,
|
||||||
|
comfy_nodes: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ hits: [] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
|
||||||
|
|
||||||
|
const provider = useAlgoliaSearchProvider()
|
||||||
|
const result = await provider.searchPacks('test', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.nodePacks[0].id).toBe('pack-id-only')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearSearchCache', () => {
|
||||||
|
it('should clear the cache', async () => {
|
||||||
|
mockSearchClient.search.mockResolvedValue({
|
||||||
|
results: [{ hits: [] }, { hits: [] }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const provider = useAlgoliaSearchProvider()
|
||||||
|
const params = { pageSize: 10, pageNumber: 0 }
|
||||||
|
|
||||||
|
// Populate cache
|
||||||
|
await provider.searchPacks('test', params)
|
||||||
|
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
provider.clearSearchCache()
|
||||||
|
|
||||||
|
// Same search should hit API again
|
||||||
|
await provider.searchPacks('test', params)
|
||||||
|
expect(mockSearchClient.search).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSortValue', () => {
|
||||||
|
const testPack = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Pack',
|
||||||
|
downloads: 100,
|
||||||
|
publisher: { id: 'pub1', name: 'Publisher One' },
|
||||||
|
latest_version: {
|
||||||
|
version: '1.0.0',
|
||||||
|
createdAt: '2024-01-15T10:00:00Z'
|
||||||
|
},
|
||||||
|
create_time: '2024-01-01T10:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should return correct values for each sort field', () => {
|
||||||
|
const provider = useAlgoliaSearchProvider()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.getSortValue(testPack, SortableAlgoliaField.Downloads)
|
||||||
|
).toBe(100)
|
||||||
|
expect(provider.getSortValue(testPack, SortableAlgoliaField.Name)).toBe(
|
||||||
|
'Test Pack'
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
provider.getSortValue(testPack, SortableAlgoliaField.Publisher)
|
||||||
|
).toBe('Publisher One')
|
||||||
|
|
||||||
|
const createdTimestamp = new Date('2024-01-01T10:00:00Z').getTime()
|
||||||
|
expect(
|
||||||
|
provider.getSortValue(testPack as any, SortableAlgoliaField.Created)
|
||||||
|
).toBe(createdTimestamp)
|
||||||
|
|
||||||
|
const updatedTimestamp = new Date('2024-01-15T10:00:00Z').getTime()
|
||||||
|
expect(
|
||||||
|
provider.getSortValue(testPack, SortableAlgoliaField.Updated)
|
||||||
|
).toBe(updatedTimestamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing values', () => {
|
||||||
|
const incompletePack = { id: '1', name: 'Incomplete' }
|
||||||
|
const provider = useAlgoliaSearchProvider()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
provider.getSortValue(incompletePack, SortableAlgoliaField.Downloads)
|
||||||
|
).toBe(0)
|
||||||
|
expect(
|
||||||
|
provider.getSortValue(incompletePack, SortableAlgoliaField.Publisher)
|
||||||
|
).toBe('')
|
||||||
|
expect(
|
||||||
|
provider.getSortValue(
|
||||||
|
incompletePack as any,
|
||||||
|
SortableAlgoliaField.Created
|
||||||
|
)
|
||||||
|
).toBe(0)
|
||||||
|
expect(
|
||||||
|
provider.getSortValue(incompletePack, SortableAlgoliaField.Updated)
|
||||||
|
).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSortableFields', () => {
|
||||||
|
it('should return all Algolia sort fields', () => {
|
||||||
|
const provider = useAlgoliaSearchProvider()
|
||||||
|
const fields = provider.getSortableFields()
|
||||||
|
|
||||||
|
expect(fields).toEqual([
|
||||||
|
{
|
||||||
|
id: SortableAlgoliaField.Downloads,
|
||||||
|
label: 'Downloads',
|
||||||
|
direction: 'desc'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SortableAlgoliaField.Created,
|
||||||
|
label: 'Created',
|
||||||
|
direction: 'desc'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SortableAlgoliaField.Updated,
|
||||||
|
label: 'Updated',
|
||||||
|
direction: 'desc'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: SortableAlgoliaField.Publisher,
|
||||||
|
label: 'Publisher',
|
||||||
|
direction: 'asc'
|
||||||
|
},
|
||||||
|
{ id: SortableAlgoliaField.Name, label: 'Name', direction: 'asc' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('memoization', () => {
|
||||||
|
it('should memoize toRegistryPack conversions', async () => {
|
||||||
|
const mockHit = {
|
||||||
|
objectID: 'algolia-1',
|
||||||
|
id: 'pack-1',
|
||||||
|
name: 'Test Pack',
|
||||||
|
publisher_id: 'pub1',
|
||||||
|
total_install: 100,
|
||||||
|
comfy_nodes: []
|
||||||
|
}
|
||||||
|
|
||||||
|
mockSearchClient.search.mockResolvedValue({
|
||||||
|
results: [
|
||||||
|
{ hits: [mockHit, mockHit, mockHit] }, // Same object 3 times
|
||||||
|
{ hits: [] }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const provider = useAlgoliaSearchProvider()
|
||||||
|
const result = await provider.searchPacks('test', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// All 3 results should be the same object reference due to memoization
|
||||||
|
expect(result.nodePacks[0]).toBe(result.nodePacks[1])
|
||||||
|
expect(result.nodePacks[1]).toBe(result.nodePacks[2])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
445
tests-ui/tests/services/registrySearchGateway.test.ts
Normal file
445
tests-ui/tests/services/registrySearchGateway.test.ts
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
|
||||||
|
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
|
||||||
|
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
|
||||||
|
|
||||||
|
// Mock the provider modules to control their behavior
|
||||||
|
vi.mock('@/services/providers/algoliaSearchProvider')
|
||||||
|
vi.mock('@/services/providers/registrySearchProvider')
|
||||||
|
|
||||||
|
describe('useRegistrySearchGateway', () => {
|
||||||
|
let consoleWarnSpy: any
|
||||||
|
let consoleInfoSpy: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
consoleWarnSpy.mockRestore()
|
||||||
|
consoleInfoSpy.mockRestore()
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Provider initialization', () => {
|
||||||
|
it('should initialize with both providers', () => {
|
||||||
|
const mockAlgoliaProvider = {
|
||||||
|
searchPacks: vi.fn(),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRegistryProvider = {
|
||||||
|
searchPacks: vi.fn(),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||||
|
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||||
|
mockRegistryProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
const gateway = useRegistrySearchGateway()
|
||||||
|
|
||||||
|
expect(useAlgoliaSearchProvider).toHaveBeenCalled()
|
||||||
|
expect(useComfyRegistrySearchProvider).toHaveBeenCalled()
|
||||||
|
expect(gateway).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle Algolia initialization failure gracefully', () => {
|
||||||
|
vi.mocked(useAlgoliaSearchProvider).mockImplementation(() => {
|
||||||
|
throw new Error('Algolia init failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockRegistryProvider = {
|
||||||
|
searchPacks: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||||
|
mockRegistryProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
const gateway = useRegistrySearchGateway()
|
||||||
|
|
||||||
|
// Gateway should still work with just the Registry provider
|
||||||
|
expect(gateway).toBeDefined()
|
||||||
|
expect(typeof gateway.searchPacks).toBe('function')
|
||||||
|
|
||||||
|
// Verify it can still search using the fallback provider
|
||||||
|
return expect(
|
||||||
|
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
|
||||||
|
).resolves.toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Search functionality', () => {
|
||||||
|
it('should use Algolia provider by default and fallback on failure', async () => {
|
||||||
|
const algoliaResult = {
|
||||||
|
nodePacks: [{ id: 'algolia-1', name: 'Algolia Pack' }],
|
||||||
|
querySuggestions: []
|
||||||
|
}
|
||||||
|
const registryResult = {
|
||||||
|
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
|
||||||
|
querySuggestions: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockAlgoliaProvider = {
|
||||||
|
searchPacks: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(algoliaResult)
|
||||||
|
.mockRejectedValueOnce(new Error('Algolia failed')),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRegistryProvider = {
|
||||||
|
searchPacks: vi.fn().mockResolvedValue(registryResult),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||||
|
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||||
|
mockRegistryProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
const gateway = useRegistrySearchGateway()
|
||||||
|
|
||||||
|
// First call should use Algolia
|
||||||
|
const result1 = await gateway.searchPacks('test', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0
|
||||||
|
})
|
||||||
|
expect(result1.nodePacks[0].name).toBe('Algolia Pack')
|
||||||
|
|
||||||
|
// Second call should fallback to Registry when Algolia fails
|
||||||
|
const result2 = await gateway.searchPacks('test', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0
|
||||||
|
})
|
||||||
|
expect(result2.nodePacks[0].name).toBe('Registry Pack')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when all providers fail', async () => {
|
||||||
|
const mockAlgoliaProvider = {
|
||||||
|
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRegistryProvider = {
|
||||||
|
searchPacks: vi.fn().mockRejectedValue(new Error('Registry failed')),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||||
|
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||||
|
mockRegistryProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
const gateway = useRegistrySearchGateway()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
|
||||||
|
).rejects.toThrow('All search providers failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Circuit breaker functionality', () => {
|
||||||
|
it('should switch to fallback provider after failure and log warnings', async () => {
|
||||||
|
const registryResult = {
|
||||||
|
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
|
||||||
|
querySuggestions: []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mock that fails
|
||||||
|
const mockAlgoliaProvider = {
|
||||||
|
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRegistryProvider = {
|
||||||
|
searchPacks: vi.fn().mockResolvedValue(registryResult),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||||
|
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||||
|
mockRegistryProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
const gateway = useRegistrySearchGateway()
|
||||||
|
|
||||||
|
// First call should try Algolia, fail, and use Registry
|
||||||
|
const result = await gateway.searchPacks('test', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockAlgoliaProvider.searchPacks).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(1)
|
||||||
|
expect(result.nodePacks[0].name).toBe('Registry Pack')
|
||||||
|
|
||||||
|
// Circuit breaker behavior is internal implementation detail
|
||||||
|
// We only test the observable behavior (fallback works)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have circuit breaker timeout mechanism', () => {
|
||||||
|
// This test verifies that the constants exist for circuit breaker behavior
|
||||||
|
// The actual circuit breaker logic is tested in integration with real provider behavior
|
||||||
|
expect(typeof useRegistrySearchGateway).toBe('function')
|
||||||
|
|
||||||
|
// We can test that the gateway logs circuit breaker behavior
|
||||||
|
const mockAlgoliaProvider = {
|
||||||
|
searchPacks: vi.fn().mockRejectedValue(new Error('Persistent failure')),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRegistryProvider = {
|
||||||
|
searchPacks: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||||
|
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||||
|
mockRegistryProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
const gateway = useRegistrySearchGateway()
|
||||||
|
expect(gateway).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cache management', () => {
|
||||||
|
it('should clear cache for all providers', () => {
|
||||||
|
const mockAlgoliaProvider = {
|
||||||
|
searchPacks: vi.fn(),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRegistryProvider = {
|
||||||
|
searchPacks: vi.fn(),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||||
|
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||||
|
mockRegistryProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
const gateway = useRegistrySearchGateway()
|
||||||
|
gateway.clearSearchCache()
|
||||||
|
|
||||||
|
expect(mockAlgoliaProvider.clearSearchCache).toHaveBeenCalled()
|
||||||
|
expect(mockRegistryProvider.clearSearchCache).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle cache clear failures gracefully', () => {
|
||||||
|
const mockAlgoliaProvider = {
|
||||||
|
searchPacks: vi.fn(),
|
||||||
|
clearSearchCache: vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error('Cache clear failed')
|
||||||
|
}),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRegistryProvider = {
|
||||||
|
searchPacks: vi.fn(),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||||
|
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||||
|
mockRegistryProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
const gateway = useRegistrySearchGateway()
|
||||||
|
|
||||||
|
// Should not throw when clearing cache even if one provider fails
|
||||||
|
expect(() => gateway.clearSearchCache()).not.toThrow()
|
||||||
|
|
||||||
|
// Should still attempt to clear cache for all providers
|
||||||
|
expect(mockAlgoliaProvider.clearSearchCache).toHaveBeenCalled()
|
||||||
|
expect(mockRegistryProvider.clearSearchCache).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sort functionality', () => {
|
||||||
|
it('should use sort fields from active provider', () => {
|
||||||
|
const algoliaFields = [
|
||||||
|
{ id: 'downloads', label: 'Downloads', direction: 'desc' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockAlgoliaProvider = {
|
||||||
|
searchPacks: vi.fn(),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue(algoliaFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRegistryProvider = {
|
||||||
|
searchPacks: vi.fn(),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||||
|
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||||
|
mockRegistryProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
const gateway = useRegistrySearchGateway()
|
||||||
|
const sortFields = gateway.getSortableFields()
|
||||||
|
|
||||||
|
expect(sortFields).toEqual(algoliaFields)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should switch sort fields when provider changes', async () => {
|
||||||
|
const algoliaFields = [
|
||||||
|
{ id: 'downloads', label: 'Downloads', direction: 'desc' }
|
||||||
|
]
|
||||||
|
const registryFields = [{ id: 'name', label: 'Name', direction: 'asc' }]
|
||||||
|
|
||||||
|
const mockAlgoliaProvider = {
|
||||||
|
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue(algoliaFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRegistryProvider = {
|
||||||
|
searchPacks: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue(registryFields)
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||||
|
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||||
|
mockRegistryProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
const gateway = useRegistrySearchGateway()
|
||||||
|
|
||||||
|
// Initially should use Algolia's sort fields
|
||||||
|
expect(gateway.getSortableFields()).toEqual(algoliaFields)
|
||||||
|
|
||||||
|
// Force a search to trigger provider switch
|
||||||
|
await gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
|
||||||
|
|
||||||
|
// Now should use Registry's sort fields
|
||||||
|
expect(gateway.getSortableFields()).toEqual(registryFields)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should delegate getSortValue to active provider', () => {
|
||||||
|
const mockAlgoliaProvider = {
|
||||||
|
searchPacks: vi.fn(),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn().mockReturnValue(100),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRegistryProvider = {
|
||||||
|
searchPacks: vi.fn(),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||||
|
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||||
|
mockRegistryProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
const gateway = useRegistrySearchGateway()
|
||||||
|
const pack = { id: '1', name: 'Test Pack' }
|
||||||
|
|
||||||
|
const value = gateway.getSortValue(pack, 'downloads')
|
||||||
|
|
||||||
|
expect(mockAlgoliaProvider.getSortValue).toHaveBeenCalledWith(
|
||||||
|
pack,
|
||||||
|
'downloads'
|
||||||
|
)
|
||||||
|
expect(value).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Provider recovery', () => {
|
||||||
|
it('should use fallback provider when primary fails', async () => {
|
||||||
|
const algoliaError = new Error('Algolia service unavailable')
|
||||||
|
const registryResult = {
|
||||||
|
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
|
||||||
|
querySuggestions: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockAlgoliaProvider = {
|
||||||
|
searchPacks: vi.fn().mockRejectedValue(algoliaError),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRegistryProvider = {
|
||||||
|
searchPacks: vi.fn().mockResolvedValue(registryResult),
|
||||||
|
clearSearchCache: vi.fn(),
|
||||||
|
getSortValue: vi.fn(),
|
||||||
|
getSortableFields: vi.fn().mockReturnValue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
|
||||||
|
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
|
||||||
|
mockRegistryProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
const gateway = useRegistrySearchGateway()
|
||||||
|
|
||||||
|
// Should fallback to Registry when Algolia fails
|
||||||
|
const result = await gateway.searchPacks('test', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.nodePacks[0].name).toBe('Registry Pack')
|
||||||
|
expect(mockAlgoliaProvider.searchPacks).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
// The gateway successfully handled the failure and returned results
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
186
tests-ui/tests/services/registrySearchProvider.test.ts
Normal file
186
tests-ui/tests/services/registrySearchProvider.test.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
|
||||||
|
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||||
|
|
||||||
|
// Mock the store
|
||||||
|
vi.mock('@/stores/comfyRegistryStore', () => ({
|
||||||
|
useComfyRegistryStore: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useComfyRegistrySearchProvider', () => {
|
||||||
|
const mockSearchCall = vi.fn()
|
||||||
|
const mockSearchClear = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
// Setup store mock
|
||||||
|
vi.mocked(useComfyRegistryStore).mockReturnValue({
|
||||||
|
search: {
|
||||||
|
call: mockSearchCall,
|
||||||
|
clear: mockSearchClear
|
||||||
|
}
|
||||||
|
} as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('searchPacks', () => {
|
||||||
|
it('should search for packs by name', async () => {
|
||||||
|
const mockResults = {
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Test Pack 1' },
|
||||||
|
{ id: '2', name: 'Test Pack 2' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mockSearchCall.mockResolvedValue(mockResults)
|
||||||
|
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
const result = await provider.searchPacks('test', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0,
|
||||||
|
restrictSearchableAttributes: ['name', 'description']
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockSearchCall).toHaveBeenCalledWith({
|
||||||
|
search: 'test',
|
||||||
|
comfy_node_search: undefined,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0
|
||||||
|
})
|
||||||
|
expect(result.nodePacks).toEqual(mockResults.nodes)
|
||||||
|
expect(result.querySuggestions).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should search for packs by node names', async () => {
|
||||||
|
const mockResults = {
|
||||||
|
nodes: [{ id: '1', name: 'Pack with LoadImage node' }]
|
||||||
|
}
|
||||||
|
mockSearchCall.mockResolvedValue(mockResults)
|
||||||
|
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
const result = await provider.searchPacks('LoadImage', {
|
||||||
|
pageSize: 20,
|
||||||
|
pageNumber: 1,
|
||||||
|
restrictSearchableAttributes: ['comfy_nodes']
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockSearchCall).toHaveBeenCalledWith({
|
||||||
|
search: undefined,
|
||||||
|
comfy_node_search: 'LoadImage',
|
||||||
|
limit: 20,
|
||||||
|
offset: 20
|
||||||
|
})
|
||||||
|
expect(result.nodePacks).toEqual(mockResults.nodes)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty results', async () => {
|
||||||
|
mockSearchCall.mockResolvedValue({ nodes: [] })
|
||||||
|
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
const result = await provider.searchPacks('nonexistent', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.nodePacks).toEqual([])
|
||||||
|
expect(result.querySuggestions).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle null results', async () => {
|
||||||
|
mockSearchCall.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
const result = await provider.searchPacks('test', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.nodePacks).toEqual([])
|
||||||
|
expect(result.querySuggestions).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle results without nodes property', async () => {
|
||||||
|
mockSearchCall.mockResolvedValue({})
|
||||||
|
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
const result = await provider.searchPacks('test', {
|
||||||
|
pageSize: 10,
|
||||||
|
pageNumber: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.nodePacks).toEqual([])
|
||||||
|
expect(result.querySuggestions).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clearSearchCache', () => {
|
||||||
|
it('should delegate to store search.clear', () => {
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
provider.clearSearchCache()
|
||||||
|
|
||||||
|
expect(mockSearchClear).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSortValue', () => {
|
||||||
|
const testPack = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Pack',
|
||||||
|
downloads: 100,
|
||||||
|
publisher: { id: 'pub1', name: 'Publisher One' },
|
||||||
|
latest_version: {
|
||||||
|
version: '1.0.0',
|
||||||
|
createdAt: '2024-01-15T10:00:00Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should return download count for downloads field', () => {
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
expect(provider.getSortValue(testPack, 'downloads')).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return pack name for name field', () => {
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
expect(provider.getSortValue(testPack, 'name')).toBe('Test Pack')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return publisher name for publisher field', () => {
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
expect(provider.getSortValue(testPack, 'publisher')).toBe('Publisher One')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return timestamp for updated field', () => {
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
const timestamp = new Date('2024-01-15T10:00:00Z').getTime()
|
||||||
|
expect(provider.getSortValue(testPack, 'updated')).toBe(timestamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing values gracefully', () => {
|
||||||
|
const incompletePack = { id: '1', name: 'Incomplete' }
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
|
||||||
|
expect(provider.getSortValue(incompletePack, 'downloads')).toBe(0)
|
||||||
|
expect(provider.getSortValue(incompletePack, 'publisher')).toBe('')
|
||||||
|
expect(provider.getSortValue(incompletePack, 'updated')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return 0 for unknown sort fields', () => {
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
expect(provider.getSortValue(testPack, 'unknown')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getSortableFields', () => {
|
||||||
|
it('should return supported sort fields', () => {
|
||||||
|
const provider = useComfyRegistrySearchProvider()
|
||||||
|
const fields = provider.getSortableFields()
|
||||||
|
|
||||||
|
expect(fields).toEqual([
|
||||||
|
{ id: 'downloads', label: 'Downloads', direction: 'desc' },
|
||||||
|
{ id: 'name', label: 'Name', direction: 'asc' },
|
||||||
|
{ id: 'publisher', label: 'Publisher', direction: 'asc' },
|
||||||
|
{ id: 'updated', label: 'Updated', direction: 'desc' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user