[Manager] Add registry search fallback with gateway pattern (#4187)

This commit is contained in:
Christian Byrne
2025-06-15 17:22:05 -07:00
committed by GitHub
parent d5ecfb2c99
commit 75077fe9ed
14 changed files with 1687 additions and 260 deletions

View File

@@ -32,6 +32,7 @@
v-model:sortField="sortField"
:search-results="searchResults"
:suggestions="suggestions"
:sort-options="sortOptions"
/>
<div class="flex-1 overflow-auto">
<div
@@ -179,7 +180,8 @@ const {
searchResults,
searchMode,
sortField,
suggestions
suggestions,
sortOptions
} = useRegistrySearch({
initialSortField: initialState.sortField,
initialSearchMode: initialState.searchMode,

View File

@@ -34,7 +34,7 @@
/>
<SearchFilterDropdown
v-model:modelValue="sortField"
:options="sortOptions"
:options="availableSortOptions"
:label="$t('g.sort')"
/>
</div>
@@ -56,21 +56,26 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue'
import type { NodesIndexSuggestion } from '@/types/algoliaTypes'
import {
type SearchOption,
SortableAlgoliaField
} from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
import type {
QuerySuggestion,
SearchMode,
SortableField
} from '@/types/searchServiceTypes'
const { searchResults } = defineProps<{
const { searchResults, sortOptions } = defineProps<{
searchResults?: components['schemas']['Node'][]
suggestions?: NodesIndexSuggestion[]
suggestions?: QuerySuggestion[]
sortOptions?: SortableField[]
}>()
const searchQuery = defineModel<string>('searchQuery')
const searchMode = defineModel<string>('searchMode', { default: 'packs' })
const sortField = defineModel<SortableAlgoliaField>('sortField', {
const searchMode = defineModel<SearchMode>('searchMode', { default: 'packs' })
const sortField = defineModel<string>('sortField', {
default: SortableAlgoliaField.Downloads
})
@@ -80,18 +85,19 @@ const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length
)
const sortOptions: SearchOption<SortableAlgoliaField>[] = [
{ id: SortableAlgoliaField.Downloads, label: t('manager.sort.downloads') },
{ id: SortableAlgoliaField.Created, label: t('manager.sort.created') },
{ id: SortableAlgoliaField.Updated, label: t('manager.sort.updated') },
{ id: SortableAlgoliaField.Publisher, label: t('manager.sort.publisher') },
{ id: SortableAlgoliaField.Name, label: t('g.name') }
]
const filterOptions: SearchOption<string>[] = [
const availableSortOptions = computed<SearchOption<string>[]>(() => {
if (!sortOptions) return []
return sortOptions.map((field) => ({
id: field.id,
label: field.label
}))
})
const filterOptions: SearchOption<SearchMode>[] = [
{ id: 'packs', label: t('manager.filter.nodePack') },
{ id: 'nodes', label: t('g.nodes') }
]
// When a dropdown query suggestion is selected, update the search query
const onOptionSelect = (event: AutoCompleteOptionSelectEvent) => {
searchQuery.value = event.value.query
}

View File

@@ -1,95 +1,61 @@
import { watchDebounced } from '@vueuse/core'
import type { Hit } from 'algoliasearch/dist/lite/browser'
import { memoize, orderBy } from 'lodash'
import { orderBy } from 'lodash'
import { computed, ref, watch } from 'vue'
import { useAlgoliaSearchService } from '@/services/algoliaSearchService'
import type {
AlgoliaNodePack,
NodesIndexSuggestion,
SearchAttribute
} from '@/types/algoliaTypes'
import { DEFAULT_PAGE_SIZE } from '@/constants/searchConstants'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import type { SearchAttribute } from '@/types/algoliaTypes'
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 DEFAULT_PAGE_SIZE = 64
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.
*/
export function useRegistrySearch(options: {
initialSortField?: SortableAlgoliaField
initialSearchMode?: 'nodes' | 'packs'
initialSearchQuery?: string
initialPageNumber?: number
}) {
export function useRegistrySearch(
options: {
initialSortField?: string
initialSearchMode?: SearchMode
initialSearchQuery?: string
initialPageNumber?: number
} = {}
) {
const {
initialSortField = SortableAlgoliaField.Downloads,
initialSortField = DEFAULT_SORT_FIELD,
initialSearchMode = 'packs',
initialSearchQuery = '',
initialPageNumber = 0
} = options
const isLoading = ref(false)
const sortField = ref<SortableAlgoliaField>(initialSortField)
const searchMode = ref<'nodes' | 'packs'>(initialSearchMode)
const sortField = ref<string>(initialSortField)
const searchMode = ref<SearchMode>(initialSearchMode)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const pageNumber = ref(initialPageNumber)
const searchQuery = ref(initialSearchQuery)
const results = ref<AlgoliaNodePack[]>([])
const suggestions = ref<NodesIndexSuggestion[]>([])
const searchResults = ref<RegistryNodePack[]>([])
const suggestions = ref<QuerySuggestion[]>([])
const searchAttributes = computed<SearchAttribute[]>(() =>
searchMode.value === 'nodes' ? ['comfy_nodes'] : ['name', 'description']
)
const resultsAsRegistryPacks = computed(() =>
results.value ? results.value.map(algoliaToRegistry) : []
)
const resultsAsNodes = computed(() =>
results.value
? results.value.reduce(
(acc, hit) => acc.concat(hit.comfy_nodes),
[] as string[]
)
: []
)
const searchGateway = useRegistrySearchGateway()
const { searchPacksCached, toRegistryPack, clearSearchPacksCache } =
useAlgoliaSearchService()
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 { searchPacks, clearSearchCache, getSortValue, getSortableFields } =
searchGateway
const updateSearchResults = async (options: { append?: boolean }) => {
isLoading.value = true
if (!options.append) {
pageNumber.value = 0
}
const { nodePacks, querySuggestions } = await searchPacksCached(
const { nodePacks, querySuggestions } = await searchPacks(
searchQuery.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
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(
nodePacks,
[getSortValue],
[SORT_DIRECTIONS[sortField.value]]
[(pack) => getSortValue(pack, sortField.value)],
[direction]
)
}
if (options.append && results.value?.length) {
results.value = results.value.concat(sortedPacks)
if (options.append && searchResults.value?.length) {
searchResults.value = searchResults.value.concat(sortedPacks)
} else {
results.value = sortedPacks
searchResults.value = sortedPacks
}
suggestions.value = querySuggestions
isLoading.value = false
@@ -128,6 +99,10 @@ export function useRegistrySearch(options: {
immediate: true
})
const sortOptions = computed(() => {
return getSortableFields()
})
return {
isLoading,
pageNumber,
@@ -136,8 +111,8 @@ export function useRegistrySearch(options: {
searchMode,
searchQuery,
suggestions,
searchResults: resultsAsRegistryPacks,
nodeSearchResults: resultsAsNodes,
clearCache: clearSearchPacksCache
searchResults,
sortOptions,
clearCache: clearSearchCache
}
}

View 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

View File

@@ -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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View File

@@ -12,11 +12,20 @@ type SafeNestedProperty<
> = T[K1] extends undefined | null ? undefined : NonNullable<T[K1]>[K2]
type RegistryNodePack = components['schemas']['Node']
/**
* Result of searching the Algolia index.
* Represents the entire result of a search query.
*/
export type SearchPacksResult = {
nodePacks: Hit<AlgoliaNodePack>[]
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 {
objectID: RegistryNodePack['id']
name: RegistryNodePack['name']
@@ -52,7 +61,14 @@ export interface AlgoliaNodePack {
icon_url: RegistryNodePack['icon']
}
/**
* An attribute that can be used to search the Algolia index by.
*/
export type SearchAttribute = keyof AlgoliaNodePack
/**
* Suggestion for a search query (autocomplete).
*/
export interface NodesIndexSuggestion {
nb_words: number
nodes_index: {
@@ -67,8 +83,11 @@ export interface NodesIndexSuggestion {
query: string
}
/**
* Parameters for searching the Algolia index.
*/
export type SearchNodePacksParams = BaseSearchParamsWithoutQuery & {
pageSize: number
pageNumber: number
restrictSearchableAttributes: SearchAttribute[]
restrictSearchableAttributes?: SearchAttribute[]
}

View File

@@ -3,6 +3,7 @@ import type { InjectionKey, Ref } from 'vue'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { AlgoliaNodePack } from '@/types/algoliaTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type { SearchMode } from '@/types/searchServiceTypes'
type WorkflowNodeProperties = ComfyWorkflowJSON['nodes'][0]['properties']
@@ -238,6 +239,6 @@ export interface UpdateAllPacksParams {
export interface ManagerState {
selectedTabId: ManagerTab
searchQuery: string
searchMode: 'nodes' | 'packs'
sortField: SortableAlgoliaField
searchMode: SearchMode
sortField: string
}

View 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[]
}

View 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])
})
})
})

View 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
})
})
})

View 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' }
])
})
})
})