mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-23 00:04:06 +00:00
Add Comfy Registry store and search hook (#2848)
This commit is contained in:
2
package-lock.json
generated
2
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.12.3",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.20",
|
||||
"@comfyorg/litegraph": "^0.9.6",
|
||||
@@ -116,7 +117,6 @@
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"zod-to-json-schema": "^3.24.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.20",
|
||||
"@comfyorg/litegraph": "^0.9.6",
|
||||
|
||||
106
src/composables/useCachedRequest.ts
Normal file
106
src/composables/useCachedRequest.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import QuickLRU from '@alloc/quick-lru'
|
||||
|
||||
import { paramsToCacheKey } from '@/utils/formatUtil'
|
||||
|
||||
const DEFAULT_MAX_SIZE = 50
|
||||
|
||||
export interface CachedRequestOptions {
|
||||
/**
|
||||
* Maximum number of items to store in the cache
|
||||
* @default 50
|
||||
*/
|
||||
maxSize?: number
|
||||
/**
|
||||
* Function to generate a cache key from parameters
|
||||
*/
|
||||
cacheKeyFn?: (params: unknown) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable that wraps a function with memoization, request deduplication, and abort handling.
|
||||
*/
|
||||
export function useCachedRequest<TParams, TResult>(
|
||||
requestFunction: (
|
||||
params: TParams,
|
||||
signal?: AbortSignal
|
||||
) => Promise<TResult | null>,
|
||||
options: CachedRequestOptions = {}
|
||||
) {
|
||||
const { maxSize = DEFAULT_MAX_SIZE, cacheKeyFn = paramsToCacheKey } = options
|
||||
|
||||
const cache = new QuickLRU<string, TResult | null>({ maxSize })
|
||||
const pendingRequests = new Map<string, Promise<TResult | null>>()
|
||||
const abortControllers = new Map<string, AbortController>()
|
||||
|
||||
const executeAndCacheCall = async (
|
||||
params: TParams,
|
||||
cacheKey: string
|
||||
): Promise<TResult | null> => {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
abortControllers.set(cacheKey, controller)
|
||||
|
||||
const responsePromise = requestFunction(params, controller.signal)
|
||||
pendingRequests.set(cacheKey, responsePromise)
|
||||
|
||||
const result = await responsePromise
|
||||
cache.set(cacheKey, result)
|
||||
|
||||
return result
|
||||
} catch (err) {
|
||||
// Set cache on error to prevent retrying bad requests
|
||||
cache.set(cacheKey, null)
|
||||
return null
|
||||
} finally {
|
||||
pendingRequests.delete(cacheKey)
|
||||
abortControllers.delete(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePendingRequest = async (
|
||||
pendingRequest: Promise<TResult | null>
|
||||
): Promise<TResult | null> => {
|
||||
try {
|
||||
return await pendingRequest
|
||||
} catch (err) {
|
||||
console.error('Error in pending request:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const abortAllRequests = () => {
|
||||
for (const controller of abortControllers.values()) {
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel and clear any pending requests
|
||||
*/
|
||||
const cancel = () => {
|
||||
abortAllRequests()
|
||||
abortControllers.clear()
|
||||
pendingRequests.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached version of the request function
|
||||
*/
|
||||
const call = async (params: TParams): Promise<TResult | null> => {
|
||||
const cacheKey = cacheKeyFn(params)
|
||||
|
||||
const cachedResult = cache.get(cacheKey)
|
||||
if (cachedResult !== undefined) return cachedResult
|
||||
|
||||
const pendingRequest = pendingRequests.get(cacheKey)
|
||||
if (pendingRequest) return handlePendingRequest(pendingRequest)
|
||||
|
||||
return executeAndCacheCall(params, cacheKey)
|
||||
}
|
||||
|
||||
return {
|
||||
call,
|
||||
cancel,
|
||||
clear: () => cache.clear()
|
||||
}
|
||||
}
|
||||
69
src/composables/useRegistrySearch.ts
Normal file
69
src/composables/useRegistrySearch.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { debounce } from 'lodash'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useComfyRegistryService } from '@/services/comfyRegistryService'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const SEARCH_DEBOUNCE_TIME = 256
|
||||
const DEFAULT_PAGE_SIZE = 60
|
||||
|
||||
/**
|
||||
* Composable for managing UI state of Comfy Node Registry search.
|
||||
*/
|
||||
export function useRegistrySearch() {
|
||||
const registryStore = useComfyRegistryStore()
|
||||
const registryService = useComfyRegistryService()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const pageNumber = ref(1)
|
||||
const pageSize = ref(DEFAULT_PAGE_SIZE)
|
||||
const searchResults = ref<components['schemas']['Node'][]>([])
|
||||
|
||||
const search = async () => {
|
||||
try {
|
||||
const isEmptySearch = searchQuery.value === ''
|
||||
const result = isEmptySearch
|
||||
? await registryStore.listAllPacks({
|
||||
page: pageNumber.value,
|
||||
limit: pageSize.value
|
||||
})
|
||||
: await registryService.search({
|
||||
search: searchQuery.value,
|
||||
page: pageNumber.value,
|
||||
limit: pageSize.value
|
||||
})
|
||||
|
||||
if (result) {
|
||||
searchResults.value = result.nodes || []
|
||||
} else {
|
||||
searchResults.value = []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading packs:', err)
|
||||
searchResults.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce search when query changes
|
||||
const debouncedSearch = debounce(search, SEARCH_DEBOUNCE_TIME)
|
||||
watch(() => searchQuery.value, debouncedSearch)
|
||||
|
||||
// Normal search when page number changes and on load
|
||||
watch(() => pageNumber.value, search, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
debouncedSearch.cancel() // Cancel debounced searches
|
||||
registryStore.cancelRequests() // Cancel in-flight requests
|
||||
registryStore.clearCache() // Clear cached responses
|
||||
})
|
||||
|
||||
return {
|
||||
pageNumber,
|
||||
pageSize,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
isLoading: registryService.isLoading,
|
||||
error: registryService.error
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { components, operations } from '@/types/comfyRegistryTypes'
|
||||
import { isAbortError } from '@/utils/typeGuardUtil'
|
||||
|
||||
const API_BASE_URL = 'https://api.comfy.org'
|
||||
|
||||
@@ -30,12 +31,6 @@ export const useComfyRegistryService = () => {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic error handler for API requests
|
||||
* @param err - The error object
|
||||
* @param context - Context description for the error
|
||||
* @param routeSpecificErrors - Optional map of status codes to custom error messages for specific routes
|
||||
*/
|
||||
const handleApiError = (
|
||||
err: unknown,
|
||||
context: string,
|
||||
@@ -76,7 +71,7 @@ export const useComfyRegistryService = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to execute API requests with consistent error and loading state handling
|
||||
* Execute an API request with error and loading state handling
|
||||
* @param apiCall - Function that returns a promise with the API call
|
||||
* @param errorContext - Context description for error messages
|
||||
* @param routeSpecificErrors - Optional map of status codes to custom error messages
|
||||
@@ -94,13 +89,10 @@ export const useComfyRegistryService = () => {
|
||||
const response = await apiCall()
|
||||
return response.data
|
||||
} catch (err) {
|
||||
const errorMessage = handleApiError(
|
||||
err,
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
)
|
||||
error.value = errorMessage
|
||||
// Don't treat cancellations as errors
|
||||
if (isAbortError(err)) return null
|
||||
|
||||
error.value = handleApiError(err, errorContext, routeSpecificErrors)
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
@@ -117,7 +109,8 @@ export const useComfyRegistryService = () => {
|
||||
const getNodeDef = async (
|
||||
packId: components['schemas']['Node']['id'],
|
||||
versionId: components['schemas']['NodeVersion']['id'],
|
||||
comfyNodeName: components['schemas']['ComfyNode']['comfy_node_name']
|
||||
comfyNodeName: components['schemas']['ComfyNode']['comfy_node_name'],
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
if (!comfyNodeName || !packId) return null
|
||||
if (isLocalNode(comfyNodeName, packId))
|
||||
@@ -131,7 +124,10 @@ export const useComfyRegistryService = () => {
|
||||
}
|
||||
|
||||
return executeApiRequest(
|
||||
() => registryApiClient.get<components['schemas']['ComfyNode']>(endpoint),
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['ComfyNode']>(endpoint, {
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
)
|
||||
@@ -142,7 +138,8 @@ export const useComfyRegistryService = () => {
|
||||
* Search packs using `search` param. Search individual nodes using `comfy_node_search` param.
|
||||
*/
|
||||
const search = async (
|
||||
params?: operations['searchNodes']['parameters']['query']
|
||||
params?: operations['searchNodes']['parameters']['query'],
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const endpoint = '/nodes/search'
|
||||
const errorContext = 'Failed to perform search'
|
||||
@@ -151,7 +148,7 @@ export const useComfyRegistryService = () => {
|
||||
() =>
|
||||
registryApiClient.get<
|
||||
operations['searchNodes']['responses'][200]['content']['application/json']
|
||||
>(endpoint, { params }),
|
||||
>(endpoint, { params, signal }),
|
||||
errorContext
|
||||
)
|
||||
}
|
||||
@@ -160,7 +157,8 @@ export const useComfyRegistryService = () => {
|
||||
* Get publisher information
|
||||
*/
|
||||
const getPublisherById = async (
|
||||
publisherId: components['schemas']['Publisher']['id']
|
||||
publisherId: components['schemas']['Publisher']['id'],
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const endpoint = `/publishers/${publisherId}`
|
||||
const errorContext = 'Failed to get publisher'
|
||||
@@ -169,7 +167,10 @@ export const useComfyRegistryService = () => {
|
||||
}
|
||||
|
||||
return executeApiRequest(
|
||||
() => registryApiClient.get<components['schemas']['Publisher']>(endpoint),
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['Publisher']>(endpoint, {
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
)
|
||||
@@ -180,7 +181,8 @@ export const useComfyRegistryService = () => {
|
||||
*/
|
||||
const listPacksForPublisher = async (
|
||||
publisherId: components['schemas']['Publisher']['id'],
|
||||
includeBanned?: boolean
|
||||
includeBanned?: boolean,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const params = includeBanned ? { include_banned: true } : undefined
|
||||
const endpoint = `/publishers/${publisherId}/nodes`
|
||||
@@ -193,7 +195,8 @@ export const useComfyRegistryService = () => {
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['Node'][]>(endpoint, {
|
||||
params
|
||||
params,
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
@@ -202,12 +205,11 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
/**
|
||||
* Add a review for a pack
|
||||
* @param packId - The ID of the pack
|
||||
* @param star - The star rating
|
||||
*/
|
||||
const postPackReview = async (
|
||||
packId: components['schemas']['Node']['id'],
|
||||
star: number
|
||||
star: number,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const endpoint = `/nodes/${packId}/reviews`
|
||||
const params = { star }
|
||||
@@ -220,17 +222,20 @@ export const useComfyRegistryService = () => {
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.post<components['schemas']['Node']>(endpoint, null, {
|
||||
params
|
||||
params,
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a paginated list of all packs on the registry
|
||||
*/
|
||||
const listAllPacks = async (
|
||||
params?: operations['listAllNodes']['parameters']['query']
|
||||
params?: operations['listAllNodes']['parameters']['query'],
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const endpoint = '/nodes'
|
||||
const errorContext = 'Failed to list packs'
|
||||
@@ -239,7 +244,7 @@ export const useComfyRegistryService = () => {
|
||||
() =>
|
||||
registryApiClient.get<
|
||||
operations['listAllNodes']['responses'][200]['content']['application/json']
|
||||
>(endpoint, { params }),
|
||||
>(endpoint, { params, signal }),
|
||||
errorContext
|
||||
)
|
||||
}
|
||||
@@ -249,7 +254,8 @@ export const useComfyRegistryService = () => {
|
||||
*/
|
||||
const getPackVersions = async (
|
||||
packId: components['schemas']['Node']['id'],
|
||||
params?: operations['listNodeVersions']['parameters']['query']
|
||||
params?: operations['listNodeVersions']['parameters']['query'],
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const endpoint = `/nodes/${packId}/versions`
|
||||
const errorContext = 'Failed to get pack versions'
|
||||
@@ -262,7 +268,7 @@ export const useComfyRegistryService = () => {
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['NodeVersion'][]>(
|
||||
endpoint,
|
||||
{ params }
|
||||
{ params, signal }
|
||||
),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
@@ -274,7 +280,8 @@ export const useComfyRegistryService = () => {
|
||||
*/
|
||||
const getPackByVersion = async (
|
||||
packId: components['schemas']['Node']['id'],
|
||||
versionId: components['schemas']['NodeVersion']['id']
|
||||
versionId: components['schemas']['NodeVersion']['id'],
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const endpoint = `/nodes/${packId}/versions/${versionId}`
|
||||
const errorContext = 'Failed to get pack version'
|
||||
@@ -285,7 +292,9 @@ export const useComfyRegistryService = () => {
|
||||
|
||||
return executeApiRequest(
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['NodeVersion']>(endpoint),
|
||||
registryApiClient.get<components['schemas']['NodeVersion']>(endpoint, {
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
)
|
||||
@@ -295,7 +304,8 @@ export const useComfyRegistryService = () => {
|
||||
* Get a specific pack by ID
|
||||
*/
|
||||
const getPackById = async (
|
||||
packId: operations['getNode']['parameters']['path']['nodeId']
|
||||
packId: operations['getNode']['parameters']['path']['nodeId'],
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
const endpoint = `/nodes/${packId}`
|
||||
const errorContext = 'Failed to get pack'
|
||||
@@ -304,7 +314,10 @@ export const useComfyRegistryService = () => {
|
||||
}
|
||||
|
||||
return executeApiRequest(
|
||||
() => registryApiClient.get<components['schemas']['Node']>(endpoint),
|
||||
() =>
|
||||
registryApiClient.get<components['schemas']['Node']>(endpoint, {
|
||||
signal
|
||||
}),
|
||||
errorContext,
|
||||
routeSpecificErrors
|
||||
)
|
||||
|
||||
89
src/stores/comfyRegistryStore.ts
Normal file
89
src/stores/comfyRegistryStore.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useCachedRequest } from '@/composables/useCachedRequest'
|
||||
import { useComfyRegistryService } from '@/services/comfyRegistryService'
|
||||
import type { components, operations } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const PACK_LIST_CACHE_SIZE = 20
|
||||
const PACK_BY_ID_CACHE_SIZE = 50
|
||||
|
||||
type NodePack = components['schemas']['Node']
|
||||
type ListPacksParams = operations['listAllNodes']['parameters']['query']
|
||||
type ListPacksResult =
|
||||
operations['listAllNodes']['responses'][200]['content']['application/json']
|
||||
|
||||
/**
|
||||
* Store for managing remote custom nodes
|
||||
*/
|
||||
export const useComfyRegistryStore = defineStore('comfyRegistry', () => {
|
||||
const registryService = useComfyRegistryService()
|
||||
|
||||
let listAllPacksHandler: ReturnType<
|
||||
typeof useCachedRequest<ListPacksParams, ListPacksResult>
|
||||
>
|
||||
let getPackByIdHandler: ReturnType<typeof useCachedRequest<string, NodePack>>
|
||||
|
||||
const recentListResult = ref<NodePack[]>([])
|
||||
const hasPacks = computed(() => recentListResult.value.length > 0)
|
||||
|
||||
/**
|
||||
* Get a list of all node packs from the registry
|
||||
*/
|
||||
const listAllPacks = async (params: ListPacksParams) => {
|
||||
listAllPacksHandler ??= useCachedRequest<ListPacksParams, ListPacksResult>(
|
||||
registryService.listAllPacks,
|
||||
{ maxSize: PACK_LIST_CACHE_SIZE }
|
||||
)
|
||||
|
||||
const response = await listAllPacksHandler.call(params)
|
||||
if (response) recentListResult.value = response.nodes || []
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pack by its ID from the registry
|
||||
*/
|
||||
const getPackById = async (
|
||||
packId: NodePack['id']
|
||||
): Promise<NodePack | null> => {
|
||||
if (!packId) return null
|
||||
|
||||
getPackByIdHandler ??= useCachedRequest<string, NodePack>(
|
||||
registryService.getPackById,
|
||||
{ maxSize: PACK_BY_ID_CACHE_SIZE }
|
||||
)
|
||||
|
||||
return getPackByIdHandler.call(packId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data
|
||||
*/
|
||||
const clearCache = () => {
|
||||
listAllPacksHandler?.clear()
|
||||
getPackByIdHandler?.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel any in-flight requests
|
||||
*/
|
||||
const cancelRequests = () => {
|
||||
listAllPacksHandler?.cancel()
|
||||
getPackByIdHandler?.cancel()
|
||||
}
|
||||
|
||||
return {
|
||||
recentListResult,
|
||||
hasPacks,
|
||||
|
||||
listAllPacks,
|
||||
getPackById,
|
||||
clearCache,
|
||||
cancelRequests,
|
||||
|
||||
isLoading: registryService.isLoading,
|
||||
error: registryService.error
|
||||
}
|
||||
})
|
||||
@@ -297,3 +297,18 @@ export function formatDate(text: string, date: Date) {
|
||||
return text
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cache key from parameters
|
||||
* Sorts the parameters to ensure consistent keys regardless of parameter order
|
||||
*/
|
||||
export const paramsToCacheKey = (params: unknown): string => {
|
||||
if (typeof params === 'string') return params
|
||||
if (typeof params === 'object' && params !== null)
|
||||
return Object.keys(params)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((key) => `${key}:${params[key as keyof typeof params]}`)
|
||||
.join('&')
|
||||
|
||||
return String(params)
|
||||
}
|
||||
|
||||
@@ -7,3 +7,12 @@ export function isPrimitiveNode(
|
||||
): node is PrimitiveNode & LGraphNode {
|
||||
return node.type === 'PrimitiveNode'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is an AbortError triggered by `AbortController#abort`
|
||||
* when cancelling a request.
|
||||
*/
|
||||
export const isAbortError = (
|
||||
err: unknown
|
||||
): err is DOMException & { name: 'AbortError' } =>
|
||||
err instanceof DOMException && err.name === 'AbortError'
|
||||
|
||||
233
tests-ui/tests/composables/useCachedRequest.test.ts
Normal file
233
tests-ui/tests/composables/useCachedRequest.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useCachedRequest } from '@/composables/useCachedRequest'
|
||||
|
||||
describe('useCachedRequest', () => {
|
||||
let mockRequestFn: ReturnType<typeof vi.fn>
|
||||
let abortSpy: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create a spy for the AbortController.abort method
|
||||
abortSpy = vi.fn()
|
||||
|
||||
// Mock AbortController
|
||||
vi.stubGlobal(
|
||||
'AbortController',
|
||||
class MockAbortController {
|
||||
signal = { aborted: false }
|
||||
abort = abortSpy
|
||||
}
|
||||
)
|
||||
|
||||
// Create a mock request function that returns different results based on params
|
||||
mockRequestFn = vi.fn(async (params: any, signal?: AbortSignal) => {
|
||||
// Simulate a request that takes some time
|
||||
await new Promise((resolve) => setTimeout(resolve, 8))
|
||||
|
||||
if (params === null) return null
|
||||
|
||||
// Return a result based on the params
|
||||
return { data: `Result for ${JSON.stringify(params)}` }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should cache results and not repeat calls with the same params', async () => {
|
||||
const cachedRequest = useCachedRequest(mockRequestFn)
|
||||
|
||||
// First call should make the request
|
||||
const result1 = await cachedRequest.call({ id: 1 })
|
||||
expect(result1).toEqual({ data: 'Result for {"id":1}' })
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second call with the same params should use the cache
|
||||
const result2 = await cachedRequest.call({ id: 1 })
|
||||
expect(result2).toEqual({ data: 'Result for {"id":1}' })
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1) // Still only called once
|
||||
|
||||
// Call with different params should make a new request
|
||||
const result3 = await cachedRequest.call({ id: 2 })
|
||||
expect(result3).toEqual({ data: 'Result for {"id":2}' })
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should deduplicate in-flight requests with the same params', async () => {
|
||||
const cachedRequest = useCachedRequest(mockRequestFn)
|
||||
|
||||
// Start two requests with the same params simultaneously
|
||||
const promise1 = cachedRequest.call({ id: 1 })
|
||||
const promise2 = cachedRequest.call({ id: 1 })
|
||||
|
||||
// Wait for both to complete
|
||||
const [result1, result2] = await Promise.all([promise1, promise2])
|
||||
|
||||
// Both should have the same result
|
||||
expect(result1).toEqual({ data: 'Result for {"id":1}' })
|
||||
expect(result2).toEqual({ data: 'Result for {"id":1}' })
|
||||
|
||||
// But the request function should only be called once
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not repeat requests that throw errors', async () => {
|
||||
// Create a mock function that throws an error
|
||||
const errorMockFn = vi.fn(async () => {
|
||||
throw new Error('Test error')
|
||||
})
|
||||
|
||||
const cachedRequest = useCachedRequest(errorMockFn)
|
||||
|
||||
// Make a request that will throw
|
||||
const result = await cachedRequest.call({ id: 1 })
|
||||
|
||||
// The result should be null
|
||||
expect(result).toBeNull()
|
||||
expect(errorMockFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Make the same request again
|
||||
const result2 = await cachedRequest.call({ id: 1 })
|
||||
expect(result2).toBeNull()
|
||||
|
||||
// Verify error result is cached and not called again
|
||||
expect(errorMockFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should evict least recently used entries when cache exceeds maxSize', async () => {
|
||||
// Create a cached request with a small max size
|
||||
const cachedRequest = useCachedRequest(mockRequestFn, { maxSize: 2 })
|
||||
|
||||
// Make 3 different requests to exceed the cache size
|
||||
await cachedRequest.call({ id: 1 })
|
||||
await cachedRequest.call({ id: 2 })
|
||||
await cachedRequest.call({ id: 3 })
|
||||
await cachedRequest.call({ id: 4 })
|
||||
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(4)
|
||||
|
||||
// Request id:1 again - it should have been evicted
|
||||
await cachedRequest.call({ id: 1 })
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(5)
|
||||
|
||||
// Request least recently used entries
|
||||
await cachedRequest.call({ id: 1 })
|
||||
await cachedRequest.call({ id: 4 })
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(5) // No new calls
|
||||
})
|
||||
|
||||
it('should not repeat calls with same params in different order', async () => {
|
||||
const cachedRequest = useCachedRequest(mockRequestFn)
|
||||
|
||||
// First call with params in one order
|
||||
await cachedRequest.call({ a: 1, b: 2 })
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Params in different order should still share cache key
|
||||
await cachedRequest.call({ b: 2, a: 1 })
|
||||
|
||||
// Verify request function not called again (cache hit)
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should use custom cache key function if provided', async () => {
|
||||
// Create a cache key function that sorts object keys
|
||||
const cacheKeyFn = (params: any) => {
|
||||
if (typeof params !== 'object' || params === null) return String(params)
|
||||
return JSON.stringify(
|
||||
Object.keys(params)
|
||||
.sort()
|
||||
.reduce((acc, key) => ({ ...acc, [key]: params[key] }), {})
|
||||
)
|
||||
}
|
||||
|
||||
const cachedRequest = useCachedRequest(mockRequestFn, { cacheKeyFn })
|
||||
|
||||
// First call with params in one order
|
||||
const result1 = await cachedRequest.call({ a: 1, b: 2 })
|
||||
expect(result1).toEqual({ data: 'Result for {"a":1,"b":2}' })
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second call with same params in different order should use cache
|
||||
const result2 = await cachedRequest.call({ b: 2, a: 1 })
|
||||
expect(result2).toEqual({ data: 'Result for {"a":1,"b":2}' })
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1) // Still only called once
|
||||
})
|
||||
|
||||
it('should abort requests when cancel is called', async () => {
|
||||
const cachedRequest = useCachedRequest(mockRequestFn)
|
||||
|
||||
// Start a request but don't await it
|
||||
const promise = cachedRequest.call({ id: 1 })
|
||||
|
||||
// Cancel all requests
|
||||
cachedRequest.cancel()
|
||||
|
||||
// The abort method should have been called
|
||||
expect(abortSpy).toHaveBeenCalled()
|
||||
|
||||
// The promise should still resolve (our mock doesn't actually abort)
|
||||
const result = await promise
|
||||
expect(result).toEqual({ data: 'Result for {"id":1}' })
|
||||
})
|
||||
|
||||
it('should clear the cache when clear is called', async () => {
|
||||
const cachedRequest = useCachedRequest(mockRequestFn)
|
||||
|
||||
// Make a request to populate the cache
|
||||
await cachedRequest.call({ id: 1 })
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Clear the cache
|
||||
cachedRequest.clear()
|
||||
|
||||
// Make the same request again
|
||||
await cachedRequest.call({ id: 1 })
|
||||
|
||||
// The request function should be called again
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should handle null results correctly', async () => {
|
||||
const cachedRequest = useCachedRequest(mockRequestFn)
|
||||
|
||||
// Make a request that returns null
|
||||
const result = await cachedRequest.call(null)
|
||||
expect(result).toBeNull()
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Make the same request again
|
||||
const result2 = await cachedRequest.call(null)
|
||||
expect(result2).toBeNull()
|
||||
|
||||
// Verify null result is treated as any other result (doesn't cause infinite cache miss)
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle string params correctly', async () => {
|
||||
const cachedRequest = useCachedRequest(mockRequestFn)
|
||||
|
||||
// Make requests with string params
|
||||
await cachedRequest.call('string-param')
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Verify cache hit
|
||||
await cachedRequest.call('string-param')
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle number params correctly', async () => {
|
||||
const cachedRequest = useCachedRequest(mockRequestFn)
|
||||
|
||||
// Make request with number param
|
||||
await cachedRequest.call(123)
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Verify cache hit
|
||||
await cachedRequest.call(123)
|
||||
expect(mockRequestFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
129
tests-ui/tests/store/comfyRegistryStore.test.ts
Normal file
129
tests-ui/tests/store/comfyRegistryStore.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useComfyRegistryService } from '@/services/comfyRegistryService'
|
||||
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
|
||||
import type { components, operations } from '@/types/comfyRegistryTypes'
|
||||
|
||||
vi.mock('@/services/comfyRegistryService', () => ({
|
||||
useComfyRegistryService: vi.fn()
|
||||
}))
|
||||
|
||||
const mockNodePack: components['schemas']['Node'] = {
|
||||
id: 'test-pack-id',
|
||||
name: 'Test Pack',
|
||||
description: 'A test node pack',
|
||||
downloads: 1000,
|
||||
publisher: {
|
||||
id: 'test-publisher',
|
||||
name: 'Test Publisher'
|
||||
},
|
||||
latest_version: {
|
||||
id: 'test-version',
|
||||
version: '1.0.0',
|
||||
createdAt: '2023-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
const mockListResult: operations['listAllNodes']['responses'][200]['content']['application/json'] =
|
||||
{
|
||||
nodes: [mockNodePack],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 10
|
||||
}
|
||||
|
||||
describe('useComfyRegistryStore', () => {
|
||||
let mockRegistryService: {
|
||||
isLoading: ReturnType<typeof ref<boolean>>
|
||||
error: ReturnType<typeof ref<string | null>>
|
||||
listAllPacks: ReturnType<typeof vi.fn>
|
||||
getPackById: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockRegistryService = {
|
||||
isLoading: ref(false),
|
||||
error: ref(null),
|
||||
listAllPacks: vi.fn().mockResolvedValue(mockListResult),
|
||||
getPackById: vi.fn().mockResolvedValue(mockNodePack)
|
||||
}
|
||||
|
||||
vi.mocked(useComfyRegistryService).mockReturnValue(
|
||||
mockRegistryService as any
|
||||
)
|
||||
})
|
||||
|
||||
it('should initialize with empty state', () => {
|
||||
const store = useComfyRegistryStore()
|
||||
|
||||
expect(store.recentListResult).toEqual([])
|
||||
expect(store.hasPacks).toBe(false)
|
||||
})
|
||||
|
||||
it('should fetch and store packs', async () => {
|
||||
const store = useComfyRegistryStore()
|
||||
const params = { page: 1, limit: 10 }
|
||||
|
||||
const result = await store.listAllPacks(params)
|
||||
|
||||
expect(result).toEqual(mockListResult)
|
||||
expect(store.recentListResult).toEqual(mockListResult.nodes)
|
||||
expect(store.hasPacks).toBe(true)
|
||||
expect(mockRegistryService.listAllPacks).toHaveBeenCalledWith(
|
||||
params,
|
||||
expect.any(Object) // abort signal
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle empty nodes array in response', async () => {
|
||||
const emptyResult = {
|
||||
nodes: undefined,
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 10
|
||||
}
|
||||
mockRegistryService.listAllPacks.mockResolvedValueOnce(emptyResult)
|
||||
|
||||
const store = useComfyRegistryStore()
|
||||
await store.listAllPacks({ page: 1, limit: 10 })
|
||||
|
||||
expect(store.recentListResult).toEqual([])
|
||||
expect(store.hasPacks).toBe(false)
|
||||
})
|
||||
|
||||
it('should fetch a pack by ID', async () => {
|
||||
const store = useComfyRegistryStore()
|
||||
const packId = 'test-pack-id'
|
||||
|
||||
const result = await store.getPackById(packId)
|
||||
|
||||
expect(result).toEqual(mockNodePack)
|
||||
expect(mockRegistryService.getPackById).toHaveBeenCalledWith(
|
||||
packId,
|
||||
expect.any(Object) // abort signal
|
||||
)
|
||||
})
|
||||
|
||||
it('should return null when fetching a pack with null ID', async () => {
|
||||
const store = useComfyRegistryStore()
|
||||
|
||||
const result = await store.getPackById(null as any)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockRegistryService.getPackById).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle service errors gracefully', async () => {
|
||||
mockRegistryService.listAllPacks.mockResolvedValueOnce(null)
|
||||
|
||||
const store = useComfyRegistryStore()
|
||||
const result = await store.listAllPacks({ page: 1, limit: 10 })
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(store.recentListResult).toEqual([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user