Compare commits

...

2 Commits

Author SHA1 Message Date
Matt Miller
8126713177 test: cover comfyRegistryService and comfyManagerService request wrappers
Add unit tests exercising the refactored useApiRequest call sites in both
services, closing the codecov patch-coverage gap on this refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:55:02 -07:00
Matt Miller
6b61a560f7 refactor: extract shared useApiRequest composable for axios services
Four services duplicated the same axios request wrapper (isLoading/error
state, try/catch/finally, cancellation handling). Extract the plumbing into
useApiRequest({ client, mapError }) and inject the axios instance plus a
per-service error mapper so each service keeps its exact user-facing status
strings. Manager keeps its availability gate and queue-restart behavior via a
thin wrapper over the shared executeRequest.
2026-07-02 13:16:58 -07:00
8 changed files with 762 additions and 240 deletions

View File

@@ -0,0 +1,104 @@
import type { AxiosInstance, AxiosResponse } from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useApiRequest } from '@/composables/useApiRequest'
const mockIsAbortError = vi.hoisted(() => vi.fn())
vi.mock('@/utils/typeGuardUtil', () => ({
isAbortError: mockIsAbortError
}))
const client = { id: 'test-client' } as unknown as AxiosInstance
function response<T>(data: T): AxiosResponse<T> {
return { data } as AxiosResponse<T>
}
describe('useApiRequest', () => {
beforeEach(() => {
mockIsAbortError.mockReset()
mockIsAbortError.mockReturnValue(false)
})
it('returns response data and toggles loading state', async () => {
const { isLoading, error, executeRequest } = useApiRequest({
client,
mapError: vi.fn()
})
expect(isLoading.value).toBe(false)
const pending = executeRequest(async () => response('ok'), {
errorContext: 'ctx'
})
expect(isLoading.value).toBe(true)
await expect(pending).resolves.toBe('ok')
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
})
it('passes the injected client to the api call', async () => {
const apiCall = vi.fn(async () => response(1))
const { executeRequest } = useApiRequest({ client, mapError: vi.fn() })
await executeRequest(apiCall, { errorContext: 'ctx' })
expect(apiCall).toHaveBeenCalledWith(client)
})
it('maps errors through the injected mapper and stores the message', async () => {
const mapError = vi.fn(() => 'mapped message')
const routeSpecificErrors = { 404: 'nope' }
const boom = new Error('boom')
const { error, executeRequest } = useApiRequest({ client, mapError })
const result = await executeRequest(
() => {
throw boom
},
{ errorContext: 'ctx', routeSpecificErrors }
)
expect(result).toBeNull()
expect(mapError).toHaveBeenCalledWith(boom, 'ctx', routeSpecificErrors)
expect(error.value).toBe('mapped message')
})
it('swallows cancellations without mapping an error', async () => {
mockIsAbortError.mockReturnValue(true)
const mapError = vi.fn(() => 'should not run')
const { error, executeRequest } = useApiRequest({ client, mapError })
const result = await executeRequest(
() => {
throw new Error('aborted')
},
{ errorContext: 'ctx' }
)
expect(result).toBeNull()
expect(mapError).not.toHaveBeenCalled()
expect(error.value).toBeNull()
})
it('runs onSuccess only after a successful response', async () => {
const onSuccess = vi.fn()
const { executeRequest } = useApiRequest({ client, mapError: vi.fn() })
await executeRequest(async () => response('ok'), {
errorContext: 'ctx',
onSuccess
})
expect(onSuccess).toHaveBeenCalledTimes(1)
onSuccess.mockClear()
await executeRequest(
() => {
throw new Error('boom')
},
{ errorContext: 'ctx', onSuccess }
)
expect(onSuccess).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,63 @@
import type { AxiosInstance, AxiosResponse } from 'axios'
import { ref } from 'vue'
import { isAbortError } from '@/utils/typeGuardUtil'
/**
* Maps a caught request error to a user-facing message string.
* Each service injects its own mapper so it keeps control of the exact
* status-to-message copy it presents.
*/
export type ApiErrorMapper = (
err: unknown,
errorContext: string,
routeSpecificErrors?: Record<number, string>
) => string
export interface ExecuteRequestOptions {
errorContext: string
routeSpecificErrors?: Record<number, string>
/** Side effect run after a successful response, before the data is returned. */
onSuccess?: () => unknown
}
/**
* Shared axios request wrapper: owns the `isLoading`/`error` state and the
* try/catch/finally plumbing, while the caller injects the axios instance and
* an error mapper. Cancellations are swallowed (no error set, `null` returned).
*/
export function useApiRequest({
client,
mapError
}: {
client: AxiosInstance
mapError: ApiErrorMapper
}) {
const isLoading = ref(false)
const error = ref<string | null>(null)
async function executeRequest<T>(
apiCall: (client: AxiosInstance) => Promise<AxiosResponse<T>>,
options: ExecuteRequestOptions
): Promise<T | null> {
const { errorContext, routeSpecificErrors, onSuccess } = options
isLoading.value = true
error.value = null
try {
const response = await apiCall(client)
await onSuccess?.()
return response.data
} catch (err) {
if (isAbortError(err)) return null
error.value = mapError(err, errorContext, routeSpecificErrors)
return null
} finally {
isLoading.value = false
}
}
return { isLoading, error, executeRequest }
}

View File

@@ -1,10 +1,10 @@
import type { AxiosError, AxiosResponse } from 'axios'
import type { AxiosError } from 'axios'
import axios from 'axios'
import { ref, watch } from 'vue'
import { watch } from 'vue'
import { useApiRequest } from '@/composables/useApiRequest'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
// Use generated types from OpenAPI spec
export type ReleaseNote = components['schemas']['ReleaseNote']
@@ -22,9 +22,6 @@ const releaseApiClient = axios.create({
// Release service for fetching release notes
export const useReleaseService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
watch(
() => getComfyApiBaseUrl(),
(url) => {
@@ -32,10 +29,7 @@ export const useReleaseService = () => {
}
)
// No transformation needed - API response matches the generated type
// Handle API errors with context
const handleApiError = (
const mapError = (
err: unknown,
context: string,
routeSpecificErrors?: Record<number, string>
@@ -72,28 +66,10 @@ export const useReleaseService = () => {
return `${context}: ${axiosError.message}`
}
// Execute API request with error handling
const executeApiRequest = async <T>(
apiCall: () => Promise<AxiosResponse<T>>,
errorContext: string,
routeSpecificErrors?: Record<number, string>
): Promise<T | null> => {
isLoading.value = true
error.value = null
try {
const response = await apiCall()
return response.data
} catch (err) {
// Don't treat cancellations as errors
if (isAbortError(err)) return null
error.value = handleApiError(err, errorContext, routeSpecificErrors)
return null
} finally {
isLoading.value = false
}
}
const { isLoading, error, executeRequest } = useApiRequest({
client: releaseApiClient,
mapError
})
// Fetch release notes from API
const getReleases = async (
@@ -107,17 +83,16 @@ export const useReleaseService = () => {
400: 'Invalid project or version parameter'
}
const apiResponse = await executeApiRequest(
() =>
releaseApiClient.get<ReleaseNote[]>(endpoint, {
const apiResponse = await executeRequest(
(client) =>
client.get<ReleaseNote[]>(endpoint, {
params,
signal,
headers: deployEnvironment
? { 'Comfy-Env': deployEnvironment }
: undefined
}),
errorContext,
routeSpecificErrors
{ errorContext, routeSpecificErrors }
)
return apiResponse

View File

@@ -0,0 +1,198 @@
import axios from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn()
}))
vi.mock('axios', () => ({
default: {
create: vi.fn(() => mockAxiosInstance),
isAxiosError: vi.fn()
}
}))
describe('useComfyRegistryService', () => {
let service: ReturnType<typeof useComfyRegistryService>
beforeEach(() => {
vi.clearAllMocks()
mockAxiosInstance.get.mockResolvedValue({ data: {} })
mockAxiosInstance.post.mockResolvedValue({ data: {} })
service = useComfyRegistryService()
})
it('initializes with idle state', () => {
expect(service.isLoading.value).toBe(false)
expect(service.error.value).toBeNull()
})
describe('request routing', () => {
it('getNodeDefs hits the comfy-nodes endpoint', async () => {
await service.getNodeDefs({ packId: 'pack', version: '1.0.0' })
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'/nodes/pack/versions/1.0.0/comfy-nodes',
expect.objectContaining({ params: {} })
)
})
it('getNodeDefs returns null without a packId or version', async () => {
const result = await service.getNodeDefs({ packId: '', version: '' })
expect(result).toBeNull()
expect(mockAxiosInstance.get).not.toHaveBeenCalled()
})
it('search hits the search endpoint', async () => {
await service.search({ search: 'sampler' })
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'/nodes/search',
expect.objectContaining({ params: { search: 'sampler' } })
)
})
it('getPublisherById hits the publisher endpoint', async () => {
await service.getPublisherById('pub-1')
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'/publishers/pub-1',
expect.any(Object)
)
})
it('listPacksForPublisher forwards include_banned', async () => {
await service.listPacksForPublisher('pub-1', true)
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'/publishers/pub-1/nodes',
expect.objectContaining({ params: { include_banned: true } })
)
})
it('postPackReview posts the star rating', async () => {
await service.postPackReview('pack', 5)
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/nodes/pack/reviews',
null,
expect.objectContaining({ params: { star: 5 } })
)
})
it('listAllPacks hits the nodes endpoint', async () => {
await service.listAllPacks({ page: 1 })
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'/nodes',
expect.objectContaining({ params: { page: 1 } })
)
})
it('getPackVersions hits the versions endpoint', async () => {
await service.getPackVersions('pack')
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'/nodes/pack/versions',
expect.any(Object)
)
})
it('getPackByVersion hits the specific version endpoint', async () => {
await service.getPackByVersion('pack', 'v-1')
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'/nodes/pack/versions/v-1',
expect.any(Object)
)
})
it('getPackById hits the node endpoint', async () => {
await service.getPackById('pack')
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'/nodes/pack',
expect.any(Object)
)
})
it('inferPackFromNodeName hits the comfy-nodes lookup endpoint', async () => {
await service.inferPackFromNodeName('KSampler')
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'/comfy-nodes/KSampler/node',
expect.any(Object)
)
})
it('getBulkNodeVersions posts the identifiers', async () => {
const nodeVersions = [{ node_id: 'pack', version: '1.0.0' }]
await service.getBulkNodeVersions(nodeVersions)
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/bulk/nodes/versions',
{ node_versions: nodeVersions },
expect.any(Object)
)
})
it('returns the response data on success', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: { id: 'pack' } })
const result = await service.getPackById('pack')
expect(result).toEqual({ id: 'pack' })
})
})
describe('error mapping', () => {
it('prefers a route-specific message for a matching status', async () => {
mockAxiosInstance.get.mockRejectedValue({
response: { status: 404, data: {} }
})
vi.mocked(axios.isAxiosError).mockReturnValue(true)
const result = await service.getPackById('missing')
expect(result).toBeNull()
expect(service.error.value).toBe(
'Pack not found: The pack with ID missing does not exist'
)
})
it('maps generic status codes to friendly messages', async () => {
mockAxiosInstance.get.mockRejectedValue({
response: { status: 401, data: {} }
})
vi.mocked(axios.isAxiosError).mockReturnValue(true)
await service.search()
expect(service.error.value).toBe('Unauthorized: Authentication required')
})
it('falls back to the axios message when there is no response', async () => {
mockAxiosInstance.get.mockRejectedValue({ message: 'Network Error' })
vi.mocked(axios.isAxiosError).mockReturnValue(true)
await service.search()
expect(service.error.value).toBe(
'Failed to perform search: Network Error'
)
})
it('handles non-axios errors', async () => {
mockAxiosInstance.get.mockRejectedValue(new Error('boom'))
vi.mocked(axios.isAxiosError).mockReturnValue(false)
await service.search()
expect(service.error.value).toBe('Failed to perform search: boom')
})
})
})

View File

@@ -1,9 +1,8 @@
import type { AxiosError, AxiosResponse } from 'axios'
import type { AxiosError } from 'axios'
import axios from 'axios'
import { ref } from 'vue'
import { useApiRequest } from '@/composables/useApiRequest'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const API_BASE_URL = 'https://api.comfy.org'
@@ -22,10 +21,7 @@ const registryApiClient = axios.create({
* Service for interacting with the Comfy Registry API
*/
export const useComfyRegistryService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
const handleApiError = (
const mapError = (
err: unknown,
context: string,
routeSpecificErrors?: Record<number, string>
@@ -64,34 +60,10 @@ export const useComfyRegistryService = () => {
return `${context}: ${axiosError.message}`
}
/**
* 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
* @returns Promise with the API response data or null if the request failed
*/
const executeApiRequest = async <T>(
apiCall: () => Promise<AxiosResponse<T>>,
errorContext: string,
routeSpecificErrors?: Record<number, string>
): Promise<T | null> => {
isLoading.value = true
error.value = null
try {
const response = await apiCall()
return response.data
} catch (err) {
// Don't treat cancellations as errors
if (isAbortError(err)) return null
error.value = handleApiError(err, errorContext, routeSpecificErrors)
return null
} finally {
isLoading.value = false
}
}
const { isLoading, error, executeRequest } = useApiRequest({
client: registryApiClient,
mapError
})
/**
* Get the Comfy Node definitions in a specific version of a node pack
@@ -116,16 +88,15 @@ export const useComfyRegistryService = () => {
404: 'The requested node, version, or comfy node does not exist'
}
return executeApiRequest(
() =>
registryApiClient.get<
return executeRequest(
(client) =>
client.get<
operations['ListComfyNodes']['responses'][200]['content']['application/json']
>(endpoint, {
params: queryParams,
signal
}),
errorContext,
routeSpecificErrors
{ errorContext, routeSpecificErrors }
)
}
@@ -140,12 +111,12 @@ export const useComfyRegistryService = () => {
const endpoint = '/nodes/search'
const errorContext = 'Failed to perform search'
return executeApiRequest(
() =>
registryApiClient.get<
return executeRequest(
(client) =>
client.get<
operations['searchNodes']['responses'][200]['content']['application/json']
>(endpoint, { params, signal }),
errorContext
{ errorContext }
)
}
@@ -162,13 +133,12 @@ export const useComfyRegistryService = () => {
404: `Publisher not found: The publisher with ID ${publisherId} does not exist`
}
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['Publisher']>(endpoint, {
return executeRequest(
(client) =>
client.get<components['schemas']['Publisher']>(endpoint, {
signal
}),
errorContext,
routeSpecificErrors
{ errorContext, routeSpecificErrors }
)
}
@@ -188,14 +158,13 @@ export const useComfyRegistryService = () => {
404: `Publisher not found: The publisher with ID ${publisherId} does not exist`
}
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['Node'][]>(endpoint, {
return executeRequest(
(client) =>
client.get<components['schemas']['Node'][]>(endpoint, {
params,
signal
}),
errorContext,
routeSpecificErrors
{ errorContext, routeSpecificErrors }
)
}
@@ -215,14 +184,13 @@ export const useComfyRegistryService = () => {
404: `Pack not found: Pack with ID ${packId} does not exist`
}
return executeApiRequest(
() =>
registryApiClient.post<components['schemas']['Node']>(endpoint, null, {
return executeRequest(
(client) =>
client.post<components['schemas']['Node']>(endpoint, null, {
params,
signal
}),
errorContext,
routeSpecificErrors
{ errorContext, routeSpecificErrors }
)
}
@@ -236,12 +204,12 @@ export const useComfyRegistryService = () => {
const endpoint = '/nodes'
const errorContext = 'Failed to list packs'
return executeApiRequest(
() =>
registryApiClient.get<
return executeRequest(
(client) =>
client.get<
operations['listAllNodes']['responses'][200]['content']['application/json']
>(endpoint, { params, signal }),
errorContext
{ errorContext }
)
}
@@ -260,14 +228,13 @@ export const useComfyRegistryService = () => {
404: `Pack not found: Pack with ID ${packId} does not exist`
}
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['NodeVersion'][]>(
endpoint,
{ params, signal }
),
errorContext,
routeSpecificErrors
return executeRequest(
(client) =>
client.get<components['schemas']['NodeVersion'][]>(endpoint, {
params,
signal
}),
{ errorContext, routeSpecificErrors }
)
}
@@ -286,13 +253,12 @@ export const useComfyRegistryService = () => {
404: `Pack not found: Pack with ID ${packId} does not exist`
}
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['NodeVersion']>(endpoint, {
return executeRequest(
(client) =>
client.get<components['schemas']['NodeVersion']>(endpoint, {
signal
}),
errorContext,
routeSpecificErrors
{ errorContext, routeSpecificErrors }
)
}
@@ -309,13 +275,12 @@ export const useComfyRegistryService = () => {
404: `Pack not found: The pack with ID ${packId} does not exist`
}
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['Node']>(endpoint, {
return executeRequest(
(client) =>
client.get<components['schemas']['Node']>(endpoint, {
signal
}),
errorContext,
routeSpecificErrors
{ errorContext, routeSpecificErrors }
)
}
@@ -350,13 +315,12 @@ export const useComfyRegistryService = () => {
404: `Comfy node not found: The node with name ${nodeName} does not exist in the registry`
}
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['Node']>(endpoint, {
return executeRequest(
(client) =>
client.get<components['schemas']['Node']>(endpoint, {
signal
}),
errorContext,
routeSpecificErrors
{ errorContext, routeSpecificErrors }
)
}
@@ -397,15 +361,16 @@ export const useComfyRegistryService = () => {
node_versions: nodeVersions
}
return executeApiRequest(
() =>
registryApiClient.post<
components['schemas']['BulkNodeVersionsResponse']
>(endpoint, requestBody, {
signal
}),
errorContext,
routeSpecificErrors
return executeRequest(
(client) =>
client.post<components['schemas']['BulkNodeVersionsResponse']>(
endpoint,
requestBody,
{
signal
}
),
{ errorContext, routeSpecificErrors }
)
}

View File

@@ -1,13 +1,13 @@
import type { AxiosError, AxiosResponse } from 'axios'
import type { AxiosError } from 'axios'
import axios from 'axios'
import { ref, watch } from 'vue'
import { watch } from 'vue'
import { useApiRequest } from '@/composables/useApiRequest'
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { d, t } from '@/i18n'
import { useAuthStore } from '@/stores/authStore'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
export enum EventType {
CREDIT_ADDED = 'credit_added',
@@ -34,9 +34,6 @@ const customerApiClient = axios.create({
attachUnifiedRemintInterceptor(customerApiClient)
export const useCustomerEventsService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
watch(
() => getComfyApiBaseUrl(),
(url) => {
@@ -44,54 +41,31 @@ export const useCustomerEventsService = () => {
}
)
const handleRequestError = (
const mapError = (
err: unknown,
context: string,
routeSpecificErrors?: Record<number, string>
) => {
// Don't treat cancellation as an error
if (isAbortError(err)) return
let message: string
): string => {
if (!axios.isAxiosError(err)) {
message = `${context} failed: ${err instanceof Error ? err.message : String(err)}`
} else {
const axiosError = err as AxiosError<{ message: string }>
const status = axiosError.response?.status
if (status && routeSpecificErrors?.[status]) {
message = routeSpecificErrors[status]
} else {
message =
axiosError.response?.data?.message ??
`${context} failed with status ${status}`
}
return `${context} failed: ${err instanceof Error ? err.message : String(err)}`
}
error.value = message
const axiosError = err as AxiosError<{ message: string }>
const status = axiosError.response?.status
if (status && routeSpecificErrors?.[status]) {
return routeSpecificErrors[status]
}
return (
axiosError.response?.data?.message ??
`${context} failed with status ${status}`
)
}
const executeRequest = async <T>(
requestCall: () => Promise<AxiosResponse<T>>,
options: {
errorContext: string
routeSpecificErrors?: Record<number, string>
}
): Promise<T | null> => {
const { errorContext, routeSpecificErrors } = options
isLoading.value = true
error.value = null
try {
const response = await requestCall()
return response.data
} catch (err) {
handleRequestError(err, errorContext, routeSpecificErrors)
return null
} finally {
isLoading.value = false
}
}
const { isLoading, error, executeRequest } = useApiRequest({
client: customerApiClient,
mapError
})
function formatEventType(eventType: string) {
switch (eventType) {
@@ -198,8 +172,8 @@ export const useCustomerEventsService = () => {
}
const result = await executeRequest<CustomerEventsResponse>(
() =>
customerApiClient.get('/customers/events', {
(client) =>
client.get('/customers/events', {
params: { page, limit },
headers: authHeaders
}),

View File

@@ -0,0 +1,252 @@
import axios from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn()
}))
const managerState = vi.hoisted(() => ({ isNewManagerUI: true }))
vi.mock('axios', () => ({
default: {
create: vi.fn(() => mockAxiosInstance),
isAxiosError: vi.fn()
}
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => path,
clientId: 'client-1',
initialClientId: null
}
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: () => ({
isNewManagerUI: { value: managerState.isNewManagerUI }
})
}))
vi.mock('uuid', () => ({ v4: () => 'generated-uuid' }))
describe('useComfyManagerService', () => {
let service: ReturnType<typeof useComfyManagerService>
beforeEach(() => {
vi.clearAllMocks()
managerState.isNewManagerUI = true
mockAxiosInstance.get.mockResolvedValue({ data: {} })
mockAxiosInstance.post.mockResolvedValue({ data: null })
service = useComfyManagerService()
})
it('initializes with idle state', () => {
expect(service.isLoading.value).toBe(false)
expect(service.error.value).toBeNull()
})
describe('availability gate', () => {
it('short-circuits requests when Manager is not in NEW_UI mode', async () => {
managerState.isNewManagerUI = false
const result = await service.listInstalledPacks()
expect(result).toBeNull()
expect(mockAxiosInstance.get).not.toHaveBeenCalled()
expect(service.error.value).toBe(
'Manager service is not available in current mode'
)
})
})
describe('read requests', () => {
it('getQueueStatus forwards the client_id param', async () => {
await service.getQueueStatus('abc')
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'manager/queue/status',
expect.objectContaining({ params: { client_id: 'abc' } })
)
})
it('listInstalledPacks hits the installed endpoint', async () => {
await service.listInstalledPacks()
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'customnode/installed',
expect.any(Object)
)
})
it('getImportFailInfo hits the import-fail endpoint', async () => {
await service.getImportFailInfo()
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'customnode/import_fail_info',
expect.any(Object)
)
})
it('getImportFailInfoBulk returns empty without identifiers', async () => {
const result = await service.getImportFailInfoBulk({})
expect(result).toEqual({})
expect(mockAxiosInstance.post).not.toHaveBeenCalled()
})
it('getImportFailInfoBulk posts when identifiers are present', async () => {
await service.getImportFailInfoBulk({ cnr_ids: ['a'] })
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'customnode/import_fail_info_bulk',
{ cnr_ids: ['a'] },
expect.any(Object)
)
})
it('isLegacyManagerUI hits the legacy-ui endpoint', async () => {
await service.isLegacyManagerUI()
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'manager/is_legacy_manager_ui',
expect.any(Object)
)
})
it('getTaskHistory forwards options as params', async () => {
await service.getTaskHistory({ max_items: 5 })
expect(mockAxiosInstance.get).toHaveBeenCalledWith(
'manager/queue/history',
expect.objectContaining({ params: { max_items: 5 } })
)
})
})
describe('queue operations', () => {
it('installPack queues an install task then starts the queue', async () => {
await service.installPack({
id: 'pack',
version: '1.0.0',
selected_version: '1.0.0',
mode: 'remote',
channel: 'default'
})
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'manager/queue/task',
expect.objectContaining({ kind: 'install' }),
expect.any(Object)
)
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'manager/queue/start',
null,
expect.any(Object)
)
})
it('uninstallPack queues an uninstall task', async () => {
await service.uninstallPack({ node_name: 'pack', is_unknown: false })
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'manager/queue/task',
expect.objectContaining({ kind: 'uninstall' }),
expect.any(Object)
)
})
it('updateAllPacks posts to the update_all endpoint', async () => {
await service.updateAllPacks({ mode: 'remote' })
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'manager/queue/update_all',
null,
expect.objectContaining({
params: expect.objectContaining({ mode: 'remote' })
})
)
})
it('updateComfyUI posts to the update_comfyui endpoint', async () => {
await service.updateComfyUI({ is_stable: true })
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'manager/queue/update_comfyui',
null,
expect.objectContaining({
params: expect.objectContaining({ is_stable: true })
})
)
})
it('rebootComfyUI posts to the reboot endpoint', async () => {
await service.rebootComfyUI()
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'manager/reboot',
null,
expect.any(Object)
)
})
it('startQueue posts to the start endpoint', async () => {
await service.startQueue()
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'manager/queue/start',
null,
expect.any(Object)
)
})
})
describe('error mapping', () => {
it('prefers a route-specific message for a matching status', async () => {
mockAxiosInstance.post.mockRejectedValue({
response: { status: 403, data: {} }
})
vi.mocked(axios.isAxiosError).mockReturnValue(true)
await service.rebootComfyUI()
expect(service.error.value).toBe(
'Forbidden: Rebooting ComfyUI requires security_level of middle or below'
)
})
it('maps 404 to a connection message', async () => {
mockAxiosInstance.get.mockRejectedValue({
response: { status: 404, data: {} }
})
vi.mocked(axios.isAxiosError).mockReturnValue(true)
await service.listInstalledPacks()
expect(service.error.value).toBe('Could not connect to ComfyUI-Manager')
})
it('falls back to the response message for other statuses', async () => {
mockAxiosInstance.get.mockRejectedValue({
response: { status: 500, data: { message: 'server exploded' } }
})
vi.mocked(axios.isAxiosError).mockReturnValue(true)
await service.listInstalledPacks()
expect(service.error.value).toBe('server exploded')
})
it('handles non-axios errors', async () => {
mockAxiosInstance.get.mockRejectedValue(new Error('boom'))
vi.mocked(axios.isAxiosError).mockReturnValue(false)
await service.listInstalledPacks()
expect(service.error.value).toBe('Fetching installed packs failed: boom')
})
})
})

View File

@@ -1,10 +1,9 @@
import type { AxiosError, AxiosResponse } from 'axios'
import type { AxiosError, AxiosInstance, AxiosResponse } from 'axios'
import axios from 'axios'
import { v4 as uuidv4 } from 'uuid'
import { ref } from 'vue'
import { useApiRequest } from '@/composables/useApiRequest'
import { api } from '@/scripts/api'
import { isAbortError } from '@/utils/typeGuardUtil'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
@@ -51,72 +50,64 @@ const managerApiClient = axios.create({
* Note: This service should only be used when Manager state is NEW_UI
*/
export const useComfyManagerService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
// Check if manager service should be available
const isManagerServiceAvailable = () => {
const managerState = useManagerState()
return managerState.isNewManagerUI.value
}
const handleRequestError = (
const mapError = (
err: unknown,
context: string,
routeSpecificErrors?: Record<number, string>
) => {
// Don't treat cancellation as an error
if (isAbortError(err)) return
let message: string
): string => {
if (!axios.isAxiosError(err)) {
message = `${context} failed: ${err instanceof Error ? err.message : String(err)}`
} else {
const axiosError = err as AxiosError<{ message: string }>
const status = axiosError.response?.status
if (status && routeSpecificErrors?.[status]) {
message = routeSpecificErrors[status]
} else if (status === 404) {
message = 'Could not connect to ComfyUI-Manager'
} else {
message =
axiosError.response?.data?.message ??
`${context} failed with status ${status}`
}
return `${context} failed: ${err instanceof Error ? err.message : String(err)}`
}
error.value = message
const axiosError = err as AxiosError<{ message: string }>
const status = axiosError.response?.status
if (status && routeSpecificErrors?.[status]) {
return routeSpecificErrors[status]
}
if (status === 404) {
return 'Could not connect to ComfyUI-Manager'
}
return (
axiosError.response?.data?.message ??
`${context} failed with status ${status}`
)
}
const executeRequest = async <T>(
requestCall: () => Promise<AxiosResponse<T>>,
const {
isLoading,
error,
executeRequest: sendRequest
} = useApiRequest({
client: managerApiClient,
mapError
})
const executeRequest = <T>(
apiCall: (client: AxiosInstance) => Promise<AxiosResponse<T>>,
options: {
errorContext: string
routeSpecificErrors?: Record<number, string>
isQueueOperation?: boolean
}
): Promise<T | null> => {
const { errorContext, routeSpecificErrors, isQueueOperation } = options
// Block service calls if not in NEW_UI state
if (!isManagerServiceAvailable()) {
error.value = 'Manager service is not available in current mode'
return null
return Promise.resolve(null)
}
isLoading.value = true
error.value = null
try {
const response = await requestCall()
if (isQueueOperation) await startQueue()
return response.data
} catch (err) {
handleRequestError(err, errorContext, routeSpecificErrors)
return null
} finally {
isLoading.value = false
}
const { isQueueOperation, ...requestOptions } = options
return sendRequest(apiCall, {
...requestOptions,
onSuccess: isQueueOperation ? startQueue : undefined
})
}
const startQueue = async (signal?: AbortSignal) => {
@@ -126,7 +117,7 @@ export const useComfyManagerService = () => {
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.START_QUEUE, null, { signal }),
(client) => client.post(ManagerRoute.START_QUEUE, null, { signal }),
{ errorContext, routeSpecificErrors }
)
}
@@ -135,8 +126,8 @@ export const useComfyManagerService = () => {
const errorContext = 'Getting ComfyUI-Manager queue status'
return executeRequest<ManagerQueueStatus>(
() =>
managerApiClient.get(ManagerRoute.QUEUE_STATUS, {
(client) =>
client.get(ManagerRoute.QUEUE_STATUS, {
params: client_id ? { client_id } : undefined,
signal
}),
@@ -148,7 +139,7 @@ export const useComfyManagerService = () => {
const errorContext = 'Fetching installed packs'
return executeRequest<InstalledPacksResponse>(
() => managerApiClient.get(ManagerRoute.LIST_INSTALLED, { signal }),
(client) => client.get(ManagerRoute.LIST_INSTALLED, { signal }),
{ errorContext }
)
}
@@ -157,7 +148,7 @@ export const useComfyManagerService = () => {
const errorContext = 'Fetching import failure information'
return executeRequest<Record<string, unknown>>(
() => managerApiClient.get(ManagerRoute.IMPORT_FAIL_INFO, { signal }),
(client) => client.get(ManagerRoute.IMPORT_FAIL_INFO, { signal }),
{ errorContext }
)
}
@@ -173,8 +164,8 @@ export const useComfyManagerService = () => {
}
return executeRequest<components['schemas']['ImportFailInfoBulkResponse']>(
() =>
managerApiClient.post(ManagerRoute.IMPORT_FAIL_INFO_BULK, params, {
(client) =>
client.post(ManagerRoute.IMPORT_FAIL_INFO_BULK, params, {
signal
}),
{ errorContext }
@@ -201,7 +192,7 @@ export const useComfyManagerService = () => {
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.QUEUE_TASK, task, { signal }),
(client) => client.post(ManagerRoute.QUEUE_TASK, task, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
@@ -264,8 +255,8 @@ export const useComfyManagerService = () => {
}
return executeRequest<null>(
() =>
managerApiClient.post(ManagerRoute.UPDATE_ALL, null, {
(client) =>
client.post(ManagerRoute.UPDATE_ALL, null, {
params: queryParams,
signal
}),
@@ -291,8 +282,8 @@ export const useComfyManagerService = () => {
}
return executeRequest<null>(
() =>
managerApiClient.post(ManagerRoute.UPDATE_COMFYUI, null, {
(client) =>
client.post(ManagerRoute.UPDATE_COMFYUI, null, {
params: queryParams,
signal
}),
@@ -307,7 +298,7 @@ export const useComfyManagerService = () => {
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.REBOOT, null, { signal }),
(client) => client.post(ManagerRoute.REBOOT, null, { signal }),
{ errorContext, routeSpecificErrors }
)
}
@@ -316,7 +307,7 @@ export const useComfyManagerService = () => {
const errorContext = 'Checking if user set Manager to use the legacy UI'
return executeRequest<{ is_legacy_manager_ui: boolean }>(
() => managerApiClient.get(ManagerRoute.IS_LEGACY_MANAGER_UI, { signal }),
(client) => client.get(ManagerRoute.IS_LEGACY_MANAGER_UI, { signal }),
{ errorContext }
)
}
@@ -333,8 +324,8 @@ export const useComfyManagerService = () => {
const errorContext = 'Getting ComfyUI-Manager task history'
return executeRequest<ManagerTaskHistory>(
() =>
managerApiClient.get(ManagerRoute.TASK_HISTORY, {
(client) =>
client.get(ManagerRoute.TASK_HISTORY, {
params: options,
signal
}),