Add Comfy Registry store and search hook (#2848)

This commit is contained in:
bymyself
2025-03-04 14:33:46 -07:00
committed by GitHub
parent 05b6f6d8a2
commit a415da616c
10 changed files with 698 additions and 34 deletions

2
package-lock.json generated
View File

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

View File

@@ -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",

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

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

View File

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

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

View File

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

View File

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

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

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