diff --git a/package-lock.json b/package-lock.json index e5ccd142a..c71827ef9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index f0aeec840..b314c72ec 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/composables/useCachedRequest.ts b/src/composables/useCachedRequest.ts new file mode 100644 index 000000000..3ecc69f7a --- /dev/null +++ b/src/composables/useCachedRequest.ts @@ -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( + requestFunction: ( + params: TParams, + signal?: AbortSignal + ) => Promise, + options: CachedRequestOptions = {} +) { + const { maxSize = DEFAULT_MAX_SIZE, cacheKeyFn = paramsToCacheKey } = options + + const cache = new QuickLRU({ maxSize }) + const pendingRequests = new Map>() + const abortControllers = new Map() + + const executeAndCacheCall = async ( + params: TParams, + cacheKey: string + ): Promise => { + 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 + ): Promise => { + 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 => { + 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() + } +} diff --git a/src/composables/useRegistrySearch.ts b/src/composables/useRegistrySearch.ts new file mode 100644 index 000000000..761856158 --- /dev/null +++ b/src/composables/useRegistrySearch.ts @@ -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([]) + + 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 + } +} diff --git a/src/services/comfyRegistryService.ts b/src/services/comfyRegistryService.ts index b781b0f71..15ce08bc7 100644 --- a/src/services/comfyRegistryService.ts +++ b/src/services/comfyRegistryService.ts @@ -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(endpoint), + () => + registryApiClient.get(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(endpoint), + () => + registryApiClient.get(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(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(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( 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(endpoint), + registryApiClient.get(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(endpoint), + () => + registryApiClient.get(endpoint, { + signal + }), errorContext, routeSpecificErrors ) diff --git a/src/stores/comfyRegistryStore.ts b/src/stores/comfyRegistryStore.ts new file mode 100644 index 000000000..a188430e4 --- /dev/null +++ b/src/stores/comfyRegistryStore.ts @@ -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 + > + let getPackByIdHandler: ReturnType> + + const recentListResult = ref([]) + const hasPacks = computed(() => recentListResult.value.length > 0) + + /** + * Get a list of all node packs from the registry + */ + const listAllPacks = async (params: ListPacksParams) => { + listAllPacksHandler ??= useCachedRequest( + 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 => { + if (!packId) return null + + getPackByIdHandler ??= useCachedRequest( + 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 + } +}) diff --git a/src/utils/formatUtil.ts b/src/utils/formatUtil.ts index 8a70e2782..1477e46ff 100644 --- a/src/utils/formatUtil.ts +++ b/src/utils/formatUtil.ts @@ -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) +} diff --git a/src/utils/typeGuardUtil.ts b/src/utils/typeGuardUtil.ts index a8ac480d1..7cfe44820 100644 --- a/src/utils/typeGuardUtil.ts +++ b/src/utils/typeGuardUtil.ts @@ -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' diff --git a/tests-ui/tests/composables/useCachedRequest.test.ts b/tests-ui/tests/composables/useCachedRequest.test.ts new file mode 100644 index 000000000..e5ae13471 --- /dev/null +++ b/tests-ui/tests/composables/useCachedRequest.test.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useCachedRequest } from '@/composables/useCachedRequest' + +describe('useCachedRequest', () => { + let mockRequestFn: ReturnType + let abortSpy: ReturnType + + 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) + }) +}) diff --git a/tests-ui/tests/store/comfyRegistryStore.test.ts b/tests-ui/tests/store/comfyRegistryStore.test.ts new file mode 100644 index 000000000..005832c3d --- /dev/null +++ b/tests-ui/tests/store/comfyRegistryStore.test.ts @@ -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> + error: ReturnType> + listAllPacks: ReturnType + getPackById: ReturnType + } + + 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([]) + }) +})