chore: migrate tests from tests-ui/ to colocate with source files (#7811)

## Summary

Migrates all unit tests from `tests-ui/` to colocate with their source
files in `src/`, improving discoverability and maintainability.

## Changes

- **What**: Relocated all unit tests to be adjacent to the code they
test, following the `<source>.test.ts` naming convention
- **Config**: Updated `vitest.config.ts` to remove `tests-ui` include
pattern and `@tests-ui` alias
- **Docs**: Moved testing documentation to `docs/testing/` with updated
paths and patterns

## Review Focus

- Migration patterns documented in
`temp/plans/migrate-tests-ui-to-src.md`
- Tests use `@/` path aliases instead of relative imports
- Shared fixtures placed in `__fixtures__/` directories

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7811-chore-migrate-tests-from-tests-ui-to-colocate-with-source-files-2da6d73d36508147a4cce85365dee614)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-01-05 16:32:24 -08:00
committed by GitHub
parent 832588c7a9
commit 10feb1fd5b
272 changed files with 483 additions and 1239 deletions

View File

@@ -0,0 +1,330 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAudioService } from '@/services/audioService'
import type { AudioRecordingError } from '@/services/audioService'
const mockRegister = vi.hoisted(() => vi.fn())
const mockConnect = vi.hoisted(() => vi.fn())
const mockApi = vi.hoisted(() => ({
fetchApi: vi.fn()
}))
const mockToastStore = vi.hoisted(() => ({
addAlert: vi.fn()
}))
vi.mock('extendable-media-recorder', () => ({
register: mockRegister
}))
vi.mock('extendable-media-recorder-wav-encoder', () => ({
connect: mockConnect
}))
vi.mock('@/scripts/api', () => ({
api: mockApi
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => mockToastStore)
}))
describe('useAudioService', () => {
let service: ReturnType<typeof useAudioService>
const mockBlob = new Blob(['test audio data'], { type: 'audio/wav' })
const mockUploadResponse = {
name: 'test-audio-123.wav'
}
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
mockConnect.mockResolvedValue('mock-encoder')
mockRegister.mockResolvedValue(undefined)
mockApi.fetchApi.mockResolvedValue({
status: 200,
json: () => Promise.resolve(mockUploadResponse)
})
service = useAudioService()
})
describe('initialization', () => {
it('should initialize service with required methods', () => {
expect(service).toHaveProperty('registerWavEncoder')
expect(service).toHaveProperty('stopAllTracks')
expect(service).toHaveProperty('convertBlobToFileAndSubmit')
expect(typeof service.registerWavEncoder).toBe('function')
expect(typeof service.stopAllTracks).toBe('function')
expect(typeof service.convertBlobToFileAndSubmit).toBe('function')
})
})
describe('registerWavEncoder', () => {
it('should register WAV encoder successfully on first call', async () => {
await service.registerWavEncoder()
expect(mockConnect).toHaveBeenCalledTimes(1)
expect(mockRegister).toHaveBeenCalledWith('mock-encoder')
})
it('should not register again if already registered', async () => {
await service.registerWavEncoder()
mockConnect.mockClear()
mockRegister.mockClear()
await service.registerWavEncoder()
expect(mockConnect).not.toHaveBeenCalled()
expect(mockRegister).not.toHaveBeenCalled()
})
it('should handle "already an encoder stored" error gracefully', async () => {
const error = new Error(
'There is already an encoder stored which handles exactly the same mime types.'
)
mockRegister.mockRejectedValueOnce(error)
await service.registerWavEncoder()
expect(mockConnect).toHaveBeenCalledTimes(0)
expect(mockRegister).toHaveBeenCalledTimes(0)
expect(console.error).not.toHaveBeenCalled()
})
})
describe('stopAllTracks', () => {
it('should stop all tracks in a stream', () => {
const mockTrack1 = { stop: vi.fn() }
const mockTrack2 = { stop: vi.fn() }
const mockStream = {
getTracks: vi.fn().mockReturnValue([mockTrack1, mockTrack2])
} as unknown as MediaStream
service.stopAllTracks(mockStream)
expect(mockStream.getTracks).toHaveBeenCalledTimes(1)
expect(mockTrack1.stop).toHaveBeenCalledTimes(1)
expect(mockTrack2.stop).toHaveBeenCalledTimes(1)
})
it('should handle null stream gracefully', () => {
expect(() => service.stopAllTracks(null)).not.toThrow()
})
it('should handle stream with no tracks', () => {
const mockStream = {
getTracks: vi.fn().mockReturnValue([])
} as unknown as MediaStream
expect(() => service.stopAllTracks(mockStream)).not.toThrow()
expect(mockStream.getTracks).toHaveBeenCalledTimes(1)
})
it('should handle tracks that throw on stop', () => {
const mockTrack1 = { stop: vi.fn() }
const mockTrack2 = {
stop: vi.fn().mockImplementation(() => {
throw new Error('Stop failed')
})
}
const mockStream = {
getTracks: vi.fn().mockReturnValue([mockTrack1, mockTrack2])
} as unknown as MediaStream
expect(() => service.stopAllTracks(mockStream)).toThrow()
expect(mockTrack1.stop).toHaveBeenCalledTimes(1)
expect(mockTrack2.stop).toHaveBeenCalledTimes(1)
})
})
describe('convertBlobToFileAndSubmit', () => {
it('should convert blob to file and upload successfully', async () => {
const result = await service.convertBlobToFileAndSubmit(mockBlob)
expect(mockApi.fetchApi).toHaveBeenCalledWith('/upload/image', {
method: 'POST',
body: expect.any(FormData)
})
expect(result).toBe('audio/test-audio-123.wav [temp]')
})
it('should create file with correct name and type', async () => {
const mockTimestamp = 1640995200000
vi.spyOn(Date, 'now').mockReturnValue(mockTimestamp)
await service.convertBlobToFileAndSubmit(mockBlob)
const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData
const uploadedFile = formDataCall.get('image') as File
expect(uploadedFile).toBeInstanceOf(File)
expect(uploadedFile.name).toBe(`recording-${mockTimestamp}.wav`)
expect(uploadedFile.type).toBe('audio/wav')
})
it('should set correct form data fields', async () => {
await service.convertBlobToFileAndSubmit(mockBlob)
const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData
expect(formDataCall.get('subfolder')).toBe('audio')
expect(formDataCall.get('type')).toBe('temp')
expect(formDataCall.get('image')).toBeInstanceOf(File)
})
it('should handle blob with different type', async () => {
const customBlob = new Blob(['test'], { type: 'audio/ogg' })
await service.convertBlobToFileAndSubmit(customBlob)
const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData
const uploadedFile = formDataCall.get('image') as File
expect(uploadedFile.type).toBe('audio/ogg')
})
it('should handle blob with no type', async () => {
const customBlob = new Blob(['test'])
await service.convertBlobToFileAndSubmit(customBlob)
const formDataCall = mockApi.fetchApi.mock.calls[0][1].body as FormData
const uploadedFile = formDataCall.get('image') as File
expect(uploadedFile.type).toBe('audio/wav') // Should default to audio/wav
})
it('should handle upload failure with error status', async () => {
mockApi.fetchApi.mockResolvedValueOnce({
status: 500,
statusText: 'Internal Server Error'
})
await expect(
service.convertBlobToFileAndSubmit(mockBlob)
).rejects.toThrow(
'Error uploading temp file: 500 - Internal Server Error'
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
'Error uploading temp file: 500 - Internal Server Error'
)
})
it('should handle network errors', async () => {
const networkError = new Error('Network Error')
mockApi.fetchApi.mockRejectedValueOnce(networkError)
await expect(
service.convertBlobToFileAndSubmit(mockBlob)
).rejects.toThrow('Network Error')
})
it('should handle different status codes', async () => {
const testCases = [
{ status: 400, statusText: 'Bad Request' },
{ status: 403, statusText: 'Forbidden' },
{ status: 404, statusText: 'Not Found' },
{ status: 413, statusText: 'Payload Too Large' }
]
for (const testCase of testCases) {
mockApi.fetchApi.mockResolvedValueOnce(testCase)
await expect(
service.convertBlobToFileAndSubmit(mockBlob)
).rejects.toThrow(
`Error uploading temp file: ${testCase.status} - ${testCase.statusText}`
)
expect(mockToastStore.addAlert).toHaveBeenCalledWith(
`Error uploading temp file: ${testCase.status} - ${testCase.statusText}`
)
mockToastStore.addAlert.mockClear()
}
})
it('should handle malformed response JSON', async () => {
mockApi.fetchApi.mockResolvedValueOnce({
status: 200,
json: () => Promise.reject(new Error('Invalid JSON'))
})
await expect(
service.convertBlobToFileAndSubmit(mockBlob)
).rejects.toThrow('Invalid JSON')
})
it('should handle empty response', async () => {
mockApi.fetchApi.mockResolvedValueOnce({
status: 200,
json: () => Promise.resolve({})
})
const result = await service.convertBlobToFileAndSubmit(mockBlob)
expect(result).toBe('audio/undefined [temp]')
})
})
describe('error handling', () => {
it('should handle AudioRecordingError interface correctly', () => {
const error: AudioRecordingError = {
type: 'permission',
message: 'Microphone access denied',
originalError: new Error('Permission denied')
}
expect(error.type).toBe('permission')
expect(error.message).toBe('Microphone access denied')
expect(error.originalError).toBeInstanceOf(Error)
})
it('should support all error types', () => {
const errorTypes = [
'permission',
'not_supported',
'encoder',
'recording',
'unknown'
] as const
errorTypes.forEach((type) => {
const error: AudioRecordingError = {
type,
message: `Test error for ${type}`
}
expect(error.type).toBe(type)
})
})
})
describe('edge cases', () => {
it('should handle very large blobs', async () => {
const largeData = new Array(1000000).fill('a').join('')
const largeBlob = new Blob([largeData], { type: 'audio/wav' })
const result = await service.convertBlobToFileAndSubmit(largeBlob)
expect(result).toBe('audio/test-audio-123.wav [temp]')
expect(mockApi.fetchApi).toHaveBeenCalledTimes(1)
})
it('should handle empty blob', async () => {
const emptyBlob = new Blob([], { type: 'audio/wav' })
const result = await service.convertBlobToFileAndSubmit(emptyBlob)
expect(result).toBe('audio/test-audio-123.wav [temp]')
})
})
})

View File

@@ -0,0 +1,426 @@
import axios from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
EventType,
useCustomerEventsService
} from '@/services/customerEventsService'
// Hoist the mocks to avoid hoisting issues
const mockAxiosInstance = vi.hoisted(() => ({
get: vi.fn()
}))
const mockFirebaseAuthStore = vi.hoisted(() => ({
getAuthHeader: vi.fn()
}))
const mockI18n = vi.hoisted(() => ({
d: vi.fn()
}))
// Mock dependencies
vi.mock('axios', () => ({
default: {
create: vi.fn(() => mockAxiosInstance),
isAxiosError: vi.fn()
}
}))
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => mockFirebaseAuthStore)
}))
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => mockI18n)
}))
vi.mock('@/utils/typeGuardUtil', () => ({
isAbortError: vi.fn()
}))
describe('useCustomerEventsService', () => {
let service: ReturnType<typeof useCustomerEventsService>
const mockAuthHeaders = {
Authorization: 'Bearer mock-token',
'Content-Type': 'application/json'
}
const mockEventsResponse = {
events: [
{
event_id: 'event-1',
event_type: 'credit_added',
params: {
amount: 1000,
transaction_id: 'txn-123',
payment_method: 'stripe'
},
createdAt: '2024-01-01T10:00:00Z'
},
{
event_id: 'event-2',
event_type: 'api_usage_completed',
params: {
api_name: 'Image Generation',
model: 'sdxl-base',
duration: 5000,
cost: 50
},
createdAt: '2024-01-02T10:00:00Z'
}
],
total: 2,
page: 1,
limit: 10,
totalPages: 1
}
beforeEach(() => {
vi.clearAllMocks()
// Setup default mocks
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(mockAuthHeaders)
mockI18n.d.mockImplementation((date, options) => {
// Mock i18n date formatting
if (options?.month === 'short') {
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
return date.toLocaleString()
})
service = useCustomerEventsService()
})
describe('initialization', () => {
it('should initialize with default state', () => {
expect(service.isLoading.value).toBe(false)
expect(service.error.value).toBeNull()
})
it('should initialize i18n date formatter', () => {
expect(mockI18n.d).toBeDefined()
})
})
describe('getMyEvents', () => {
it('should fetch events successfully', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: mockEventsResponse })
const result = await service.getMyEvents({
page: 1,
limit: 10
})
expect(mockFirebaseAuthStore.getAuthHeader).toHaveBeenCalled()
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/customers/events', {
params: { page: 1, limit: 10 },
headers: mockAuthHeaders
})
expect(result).toEqual(mockEventsResponse)
expect(service.isLoading.value).toBe(false)
expect(service.error.value).toBeNull()
})
it('should use default parameters when none provided', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: mockEventsResponse })
await service.getMyEvents()
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/customers/events', {
params: { page: 1, limit: 10 },
headers: mockAuthHeaders
})
})
it('should return null when auth headers are missing', async () => {
mockFirebaseAuthStore.getAuthHeader.mockResolvedValue(null)
const result = await service.getMyEvents()
expect(result).toBeNull()
expect(service.error.value).toBe('Authentication header is missing')
expect(mockAxiosInstance.get).not.toHaveBeenCalled()
})
it('should handle 400 errors', async () => {
const errorResponse = {
response: {
status: 400,
data: { message: 'Invalid input' }
}
}
mockAxiosInstance.get.mockRejectedValue(errorResponse)
vi.mocked(axios.isAxiosError).mockReturnValue(true)
const result = await service.getMyEvents()
expect(result).toBeNull()
expect(service.error.value).toBe('Invalid input, object invalid')
})
it('should handle 404 errors', async () => {
const errorResponse = {
response: {
status: 404,
data: { message: 'Not found' }
}
}
mockAxiosInstance.get.mockRejectedValue(errorResponse)
vi.mocked(axios.isAxiosError).mockReturnValue(true)
const result = await service.getMyEvents()
expect(result).toBeNull()
expect(service.error.value).toBe('Not found')
})
it('should handle network errors', async () => {
const networkError = new Error('Network Error')
mockAxiosInstance.get.mockRejectedValue(networkError)
vi.mocked(axios.isAxiosError).mockReturnValue(false)
const result = await service.getMyEvents()
expect(result).toBeNull()
expect(service.error.value).toBe(
'Fetching customer events failed: Network Error'
)
})
})
describe('formatEventType', () => {
it('should format known event types correctly', () => {
expect(service.formatEventType(EventType.CREDIT_ADDED)).toBe(
'Credits Added'
)
expect(service.formatEventType(EventType.ACCOUNT_CREATED)).toBe(
'Account Created'
)
expect(service.formatEventType(EventType.API_USAGE_COMPLETED)).toBe(
'API Usage'
)
})
it('should return the original string for unknown event types', () => {
expect(service.formatEventType('unknown_event')).toBe('unknown_event')
})
})
describe('getEventSeverity', () => {
it('should return correct severity for known event types', () => {
expect(service.getEventSeverity(EventType.CREDIT_ADDED)).toBe('success')
expect(service.getEventSeverity(EventType.ACCOUNT_CREATED)).toBe('info')
expect(service.getEventSeverity(EventType.API_USAGE_COMPLETED)).toBe(
'warning'
)
})
it('should return default severity for unknown event types', () => {
expect(service.getEventSeverity('unknown_event')).toBe('info')
})
})
describe('formatAmount', () => {
it('should format amounts correctly', () => {
expect(service.formatAmount(1000)).toBe('10.00')
expect(service.formatAmount(2550)).toBe('25.50')
expect(service.formatAmount(100)).toBe('1.00')
})
it('should handle undefined amounts', () => {
expect(service.formatAmount(undefined)).toBe('0.00')
expect(service.formatAmount(0)).toBe('0.00')
})
})
describe('formatDate', () => {
it('should use i18n date formatter', () => {
const dateString = '2024-01-01T10:00:00Z'
service.formatDate(dateString)
expect(mockI18n.d).toHaveBeenCalledWith(new Date(dateString), {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
})
it('should return formatted date string', () => {
const dateString = '2024-01-01T10:00:00Z'
const result = service.formatDate(dateString)
expect(typeof result).toBe('string')
expect(result.length).toBeGreaterThan(0)
})
})
describe('hasAdditionalInfo', () => {
it('should return true when event has additional parameters', () => {
const event = {
event_id: 'test',
event_type: 'api_usage_completed',
params: {
api_name: 'test-api',
model: 'test-model',
duration: 1000,
extra_param: 'extra_value'
},
createdAt: '2024-01-01T10:00:00Z'
}
expect(service.hasAdditionalInfo(event)).toBe(true)
})
it('should return false when event only has known parameters', () => {
const event = {
event_id: 'test',
event_type: 'api_usage_completed',
params: {
amount: 1000,
api_name: 'test-api',
model: 'test-model'
},
createdAt: '2024-01-01T10:00:00Z'
}
expect(service.hasAdditionalInfo(event)).toBe(false)
})
it('should return false when params is undefined', () => {
const event = {
event_id: 'test',
event_type: 'account_created',
params: undefined,
createdAt: '2024-01-01T10:00:00Z'
}
expect(service.hasAdditionalInfo(event)).toBe(false)
})
})
describe('getTooltipContent', () => {
it('should generate HTML tooltip content for all parameters', () => {
const event = {
event_id: 'test',
event_type: 'api_usage_completed',
params: {
transaction_id: 'txn-123',
duration: 5000,
status: 'completed'
},
createdAt: '2024-01-01T10:00:00Z'
}
const result = service.getTooltipContent(event)
expect(result).toContain('<strong>Transaction Id:</strong> txn-123')
expect(result).toContain('<strong>Duration:</strong> 5,000')
expect(result).toContain('<strong>Status:</strong> completed')
expect(result).toContain('<br>')
})
it('should return empty string when no parameters', () => {
const event = {
event_id: 'test',
event_type: 'account_created',
params: {},
createdAt: '2024-01-01T10:00:00Z'
}
expect(service.getTooltipContent(event)).toBe('')
})
it('should handle undefined params', () => {
const event = {
event_id: 'test',
event_type: 'account_created',
params: undefined,
createdAt: '2024-01-01T10:00:00Z'
}
expect(service.getTooltipContent(event)).toBe('')
})
})
describe('formatJsonKey', () => {
it('should format keys correctly', () => {
expect(service.formatJsonKey('transaction_id')).toBe('Transaction Id')
expect(service.formatJsonKey('api_name')).toBe('Api Name')
expect(service.formatJsonKey('simple')).toBe('Simple')
})
})
describe('formatJsonValue', () => {
it('should format numbers with commas', () => {
expect(service.formatJsonValue(1000)).toBe('1,000')
expect(service.formatJsonValue(1234567)).toBe('1,234,567')
})
it('should format date strings', () => {
const dateString = '2024-01-01T10:00:00Z'
const result = service.formatJsonValue(dateString)
expect(typeof result).toBe('string')
expect(result).not.toBe(dateString) // Should be formatted
})
})
describe('error handling edge cases', () => {
it('should handle non-Error objects', async () => {
const stringError = 'String error'
mockAxiosInstance.get.mockRejectedValue(stringError)
vi.mocked(axios.isAxiosError).mockReturnValue(false)
const result = await service.getMyEvents()
expect(result).toBeNull()
expect(service.error.value).toBe(
'Fetching customer events failed: String error'
)
})
it('should reset error state on new request', async () => {
// First request fails
mockAxiosInstance.get.mockRejectedValueOnce(new Error('First error'))
await service.getMyEvents()
expect(service.error.value).toBeTruthy()
// Second request succeeds
mockAxiosInstance.get.mockResolvedValueOnce({ data: mockEventsResponse })
await service.getMyEvents()
expect(service.error.value).toBeNull()
})
})
describe('EventType enum', () => {
it('should have correct enum values', () => {
expect(EventType.CREDIT_ADDED).toBe('credit_added')
expect(EventType.ACCOUNT_CREATED).toBe('account_created')
expect(EventType.API_USAGE_STARTED).toBe('api_usage_started')
expect(EventType.API_USAGE_COMPLETED).toBe('api_usage_completed')
})
})
describe('edge cases for formatting functions', () => {
it('formatJsonKey should handle empty strings', () => {
expect(service.formatJsonKey('')).toBe('')
})
it('formatJsonKey should handle single words', () => {
expect(service.formatJsonKey('test')).toBe('Test')
})
it('formatAmount should handle very large numbers', () => {
expect(service.formatAmount(999999999)).toBe('9999999.99')
})
})
})

View File

@@ -0,0 +1,445 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRegistrySearchGateway } from '@/services/gateway/registrySearchGateway'
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
// Mock the provider modules to control their behavior
vi.mock('@/services/providers/algoliaSearchProvider')
vi.mock('@/services/providers/registrySearchProvider')
describe('useRegistrySearchGateway', () => {
let consoleWarnSpy: any
let consoleInfoSpy: any
beforeEach(() => {
vi.clearAllMocks()
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {})
})
afterEach(() => {
consoleWarnSpy.mockRestore()
consoleInfoSpy.mockRestore()
vi.useRealTimers()
})
describe('Provider initialization', () => {
it('should initialize with both providers', () => {
const mockAlgoliaProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
expect(useAlgoliaSearchProvider).toHaveBeenCalled()
expect(useComfyRegistrySearchProvider).toHaveBeenCalled()
expect(gateway).toBeDefined()
})
it('should handle Algolia initialization failure gracefully', () => {
vi.mocked(useAlgoliaSearchProvider).mockImplementation(() => {
throw new Error('Algolia init failed')
})
const mockRegistryProvider = {
searchPacks: vi
.fn()
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
// Gateway should still work with just the Registry provider
expect(gateway).toBeDefined()
expect(typeof gateway.searchPacks).toBe('function')
// Verify it can still search using the fallback provider
return expect(
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
).resolves.toBeDefined()
})
})
describe('Search functionality', () => {
it('should use Algolia provider by default and fallback on failure', async () => {
const algoliaResult = {
nodePacks: [{ id: 'algolia-1', name: 'Algolia Pack' }],
querySuggestions: []
}
const registryResult = {
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
querySuggestions: []
}
const mockAlgoliaProvider = {
searchPacks: vi
.fn()
.mockResolvedValueOnce(algoliaResult)
.mockRejectedValueOnce(new Error('Algolia failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockResolvedValue(registryResult),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
// First call should use Algolia
const result1 = await gateway.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(result1.nodePacks[0].name).toBe('Algolia Pack')
// Second call should fallback to Registry when Algolia fails
const result2 = await gateway.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(result2.nodePacks[0].name).toBe('Registry Pack')
})
it('should throw error when all providers fail', async () => {
const mockAlgoliaProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Registry failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
await expect(
gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
).rejects.toThrow('All search providers failed')
})
})
describe('Circuit breaker functionality', () => {
it('should switch to fallback provider after failure and log warnings', async () => {
const registryResult = {
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
querySuggestions: []
}
// Create mock that fails
const mockAlgoliaProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockResolvedValue(registryResult),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
// First call should try Algolia, fail, and use Registry
const result = await gateway.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(mockAlgoliaProvider.searchPacks).toHaveBeenCalledTimes(1)
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(1)
expect(result.nodePacks[0].name).toBe('Registry Pack')
// Circuit breaker behavior is internal implementation detail
// We only test the observable behavior (fallback works)
})
it('should have circuit breaker timeout mechanism', () => {
// This test verifies that the constants exist for circuit breaker behavior
// The actual circuit breaker logic is tested in integration with real provider behavior
expect(typeof useRegistrySearchGateway).toBe('function')
// We can test that the gateway logs circuit breaker behavior
const mockAlgoliaProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Persistent failure')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi
.fn()
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
expect(gateway).toBeDefined()
})
})
describe('Cache management', () => {
it('should clear cache for all providers', () => {
const mockAlgoliaProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
gateway.clearSearchCache()
expect(mockAlgoliaProvider.clearSearchCache).toHaveBeenCalled()
expect(mockRegistryProvider.clearSearchCache).toHaveBeenCalled()
})
it('should handle cache clear failures gracefully', () => {
const mockAlgoliaProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn().mockImplementation(() => {
throw new Error('Cache clear failed')
}),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
// Should not throw when clearing cache even if one provider fails
expect(() => gateway.clearSearchCache()).not.toThrow()
// Should still attempt to clear cache for all providers
expect(mockAlgoliaProvider.clearSearchCache).toHaveBeenCalled()
expect(mockRegistryProvider.clearSearchCache).toHaveBeenCalled()
})
})
describe('Sort functionality', () => {
it('should use sort fields from active provider', () => {
const algoliaFields = [
{ id: 'downloads', label: 'Downloads', direction: 'desc' }
]
const mockAlgoliaProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue(algoliaFields)
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
const sortFields = gateway.getSortableFields()
expect(sortFields).toEqual(algoliaFields)
})
it('should switch sort fields when provider changes', async () => {
const algoliaFields = [
{ id: 'downloads', label: 'Downloads', direction: 'desc' }
]
const registryFields = [{ id: 'name', label: 'Name', direction: 'asc' }]
const mockAlgoliaProvider = {
searchPacks: vi.fn().mockRejectedValue(new Error('Algolia failed')),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue(algoliaFields)
}
const mockRegistryProvider = {
searchPacks: vi
.fn()
.mockResolvedValue({ nodePacks: [], querySuggestions: [] }),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue(registryFields)
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
// Initially should use Algolia's sort fields
expect(gateway.getSortableFields()).toEqual(algoliaFields)
// Force a search to trigger provider switch
await gateway.searchPacks('test', { pageSize: 10, pageNumber: 0 })
// Now should use Registry's sort fields
expect(gateway.getSortableFields()).toEqual(registryFields)
})
it('should delegate getSortValue to active provider', () => {
const mockAlgoliaProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn().mockReturnValue(100),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn(),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
const pack = { id: '1', name: 'Test Pack' }
const value = gateway.getSortValue(pack, 'downloads')
expect(mockAlgoliaProvider.getSortValue).toHaveBeenCalledWith(
pack,
'downloads'
)
expect(value).toBe(100)
})
})
describe('Provider recovery', () => {
it('should use fallback provider when primary fails', async () => {
const algoliaError = new Error('Algolia service unavailable')
const registryResult = {
nodePacks: [{ id: 'registry-1', name: 'Registry Pack' }],
querySuggestions: []
}
const mockAlgoliaProvider = {
searchPacks: vi.fn().mockRejectedValue(algoliaError),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
const mockRegistryProvider = {
searchPacks: vi.fn().mockResolvedValue(registryResult),
clearSearchCache: vi.fn(),
getSortValue: vi.fn(),
getSortableFields: vi.fn().mockReturnValue([])
}
vi.mocked(useAlgoliaSearchProvider).mockReturnValue(mockAlgoliaProvider)
vi.mocked(useComfyRegistrySearchProvider).mockReturnValue(
mockRegistryProvider
)
const gateway = useRegistrySearchGateway()
// Should fallback to Registry when Algolia fails
const result = await gateway.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(result.nodePacks[0].name).toBe('Registry Pack')
expect(mockAlgoliaProvider.searchPacks).toHaveBeenCalledTimes(1)
expect(mockRegistryProvider.searchPacks).toHaveBeenCalledTimes(1)
// The gateway successfully handled the failure and returned results
})
})
})

View File

@@ -0,0 +1,202 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { useKeybindingService } from '@/services/keybindingService'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import {
KeyComboImpl,
KeybindingImpl,
useKeybindingStore
} from '@/stores/keybindingStore'
// Mock stores
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
}))
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
dialogStack: []
}))
}))
describe('keybindingService - Escape key handling', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
let mockCommandExecute: ReturnType<typeof useCommandStore>['execute']
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
// Mock command store execute
const commandStore = useCommandStore()
mockCommandExecute = vi.fn()
commandStore.execute = mockCommandExecute
// Reset dialog store mock to empty
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: []
} as any)
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})
it('should register Escape key for ExitSubgraph command', () => {
const keybindingStore = useKeybindingStore()
// Check that the Escape keybinding exists in core keybindings
const escapeKeybinding = CORE_KEYBINDINGS.find(
(kb) =>
kb.combo.key === 'Escape' && kb.commandId === 'Comfy.Graph.ExitSubgraph'
)
expect(escapeKeybinding).toBeDefined()
// Check that it was registered in the store
const registeredBinding = keybindingStore.getKeybinding(
new KeyComboImpl({ key: 'Escape' })
)
expect(registeredBinding).toBeDefined()
expect(registeredBinding?.commandId).toBe('Comfy.Graph.ExitSubgraph')
})
it('should execute ExitSubgraph command when Escape is pressed', async () => {
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
// Mock event methods
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
expect(event.preventDefault).toHaveBeenCalled()
expect(mockCommandExecute).toHaveBeenCalledWith('Comfy.Graph.ExitSubgraph')
})
it('should not execute command when Escape is pressed with modifiers', async () => {
const event = new KeyboardEvent('keydown', {
key: 'Escape',
ctrlKey: true,
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should not execute command when typing in input field', async () => {
const inputElement = document.createElement('input')
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [inputElement])
await keybindingService.keybindHandler(event)
expect(mockCommandExecute).not.toHaveBeenCalled()
})
it('should close dialogs when no keybinding is registered', async () => {
// Remove the Escape keybinding
const keybindingStore = useKeybindingStore()
keybindingStore.unsetKeybinding(
new KeybindingImpl({
commandId: 'Comfy.Graph.ExitSubgraph',
combo: { key: 'Escape' }
})
)
// Create a mock dialog
const dialog = document.createElement('dialog')
dialog.open = true
dialog.close = vi.fn()
document.body.appendChild(dialog)
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
expect(dialog.close).toHaveBeenCalled()
expect(mockCommandExecute).not.toHaveBeenCalled()
// Cleanup
document.body.removeChild(dialog)
})
it('should allow user to rebind Escape key to different command', async () => {
const keybindingStore = useKeybindingStore()
// Add a user keybinding for Escape to a different command
keybindingStore.addUserKeybinding(
new KeybindingImpl({
commandId: 'Custom.Command',
combo: { key: 'Escape' }
})
)
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
expect(event.preventDefault).toHaveBeenCalled()
expect(mockCommandExecute).toHaveBeenCalledWith('Custom.Command')
expect(mockCommandExecute).not.toHaveBeenCalledWith(
'Comfy.Graph.ExitSubgraph'
)
})
it('should not execute Escape keybinding when dialogs are open', async () => {
// Mock dialog store to have open dialogs
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: [{ key: 'test-dialog' }]
} as any)
// Re-create keybinding service to pick up new mock
keybindingService = useKeybindingService()
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true
})
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [document.body])
await keybindingService.keybindHandler(event)
// Should not call preventDefault or execute command
expect(event.preventDefault).not.toHaveBeenCalled()
expect(mockCommandExecute).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,176 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { useKeybindingService } from '@/services/keybindingService'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
// Mock the app and canvas using factory functions
vi.mock('@/scripts/app', () => {
return {
app: {
canvas: {
processKey: vi.fn()
}
}
}
})
// Mock stores
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
}))
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
dialogStack: []
}))
}))
// Test utility for creating keyboard events with mocked methods
function createTestKeyboardEvent(
key: string,
options: {
target?: Element
ctrlKey?: boolean
altKey?: boolean
metaKey?: boolean
} = {}
): KeyboardEvent {
const {
target = document.body,
ctrlKey = false,
altKey = false,
metaKey = false
} = options
const event = new KeyboardEvent('keydown', {
key,
ctrlKey,
altKey,
metaKey,
bubbles: true,
cancelable: true
})
// Mock event methods
event.preventDefault = vi.fn()
event.composedPath = vi.fn(() => [target])
return event
}
describe('keybindingService - Event Forwarding', () => {
let keybindingService: ReturnType<typeof useKeybindingService>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
// Mock command store execute
const commandStore = useCommandStore()
commandStore.execute = vi.fn()
// Reset dialog store mock to empty
vi.mocked(useDialogStore).mockReturnValue({
dialogStack: []
} as any)
keybindingService = useKeybindingService()
keybindingService.registerCoreKeybindings()
})
it('should forward Delete key to canvas when no keybinding exists', async () => {
const event = createTestKeyboardEvent('Delete')
await keybindingService.keybindHandler(event)
// Should forward to canvas processKey
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
// Should not execute any command
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should forward Backspace key to canvas when no keybinding exists', async () => {
const event = createTestKeyboardEvent('Backspace')
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).toHaveBeenCalledWith(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should not forward Delete key when typing in input field', async () => {
const inputElement = document.createElement('input')
const event = createTestKeyboardEvent('Delete', { target: inputElement })
await keybindingService.keybindHandler(event)
// Should not forward to canvas when in input field
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should not forward Delete key when typing in textarea', async () => {
const textareaElement = document.createElement('textarea')
const event = createTestKeyboardEvent('Delete', { target: textareaElement })
await keybindingService.keybindHandler(event)
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should not forward Delete key when canvas processKey is not available', async () => {
// Temporarily replace processKey with undefined
const originalProcessKey = vi.mocked(app.canvas).processKey
vi.mocked(app.canvas).processKey = undefined as any
const event = createTestKeyboardEvent('Delete')
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
// Restore processKey for other tests
vi.mocked(app.canvas).processKey = originalProcessKey
})
it('should not forward Delete key when canvas is not available', async () => {
// Temporarily set canvas to null
const originalCanvas = vi.mocked(app).canvas
vi.mocked(app).canvas = null as any
const event = createTestKeyboardEvent('Delete')
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
// Restore canvas for other tests
vi.mocked(app).canvas = originalCanvas
})
it('should not forward non-canvas keys', async () => {
const event = createTestKeyboardEvent('Enter')
await keybindingService.keybindHandler(event)
// Should not forward Enter key
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should not forward when modifier keys are pressed', async () => {
const event = createTestKeyboardEvent('Delete', { ctrlKey: true })
await keybindingService.keybindHandler(event)
// Should not forward when modifiers are pressed
expect(vi.mocked(app.canvas.processKey)).not.toHaveBeenCalled()
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest'
import { useMediaCache } from './mediaCacheService'
// Mock fetch
global.fetch = vi.fn()
global.URL = {
createObjectURL: vi.fn(() => 'blob:mock-url'),
revokeObjectURL: vi.fn()
} as any
describe('mediaCacheService', () => {
describe('URL reference counting', () => {
it('should handle URL acquisition for non-existent cache entry', () => {
const { acquireUrl } = useMediaCache()
const url = acquireUrl('non-existent.jpg')
expect(url).toBeUndefined()
})
it('should handle URL release for non-existent cache entry', () => {
const { releaseUrl } = useMediaCache()
// Should not throw error
expect(() => releaseUrl('non-existent.jpg')).not.toThrow()
})
it('should provide acquireUrl and releaseUrl methods', () => {
const cache = useMediaCache()
expect(typeof cache.acquireUrl).toBe('function')
expect(typeof cache.releaseUrl).toBe('function')
})
})
})

View File

@@ -0,0 +1,442 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockLocalStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn()
}))
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
writable: true
})
vi.mock('@/config/version', () => ({
__COMFYUI_FRONTEND_VERSION__: '1.24.0'
}))
//@ts-expect-error Define global for the test
global.__COMFYUI_FRONTEND_VERSION__ = '1.24.0'
import type { newUserService as NewUserServiceType } from '@/services/newUserService'
describe('newUserService', () => {
let service: ReturnType<typeof NewUserServiceType>
let mockSettingStore: any
let newUserService: typeof NewUserServiceType
beforeEach(async () => {
vi.clearAllMocks()
vi.resetModules()
const module = await import('@/services/newUserService')
newUserService = module.newUserService
service = newUserService()
mockSettingStore = {
settingValues: {},
get: vi.fn(),
set: vi.fn()
}
mockLocalStorage.getItem.mockReturnValue(null)
})
describe('checkIsNewUser logic', () => {
it('should identify new user when all conditions are met', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(true)
})
it('should identify new user when settings exist but TutorialCompleted is undefined', async () => {
mockSettingStore.settingValues = { 'some.setting': 'value' }
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(true)
})
it('should identify existing user when tutorial is completed', async () => {
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': true }
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return true
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(false)
})
it('should identify existing user when workflow exists', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'workflow') return 'some-workflow'
return null
})
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(false)
})
it('should identify existing user when previous workflow exists', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'Comfy.PreviousWorkflow') return 'some-previous-workflow'
return null
})
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(false)
})
it('should identify new user when tutorial is explicitly false', async () => {
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': false }
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return false
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(true)
})
it('should identify existing user when has both settings and tutorial completed', async () => {
mockSettingStore.settingValues = {
'some.setting': 'value',
'Comfy.TutorialCompleted': true
}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return true
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(false)
})
it('should identify existing user when only one condition fails', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'workflow') return 'some-workflow'
if (key === 'Comfy.PreviousWorkflow') return null
return null
})
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(false)
})
})
describe('registerInitCallback', () => {
it('should execute callback immediately if new user is already determined', async () => {
const mockCallback = vi.fn().mockResolvedValue(undefined)
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(true)
await service.registerInitCallback(mockCallback)
expect(mockCallback).toHaveBeenCalledTimes(1)
})
it('should queue callbacks when user status is not determined', async () => {
const mockCallback = vi.fn().mockResolvedValue(undefined)
await service.registerInitCallback(mockCallback)
expect(mockCallback).not.toHaveBeenCalled()
expect(service.isNewUser()).toBeNull()
})
it('should handle callback errors gracefully', async () => {
const mockCallback = vi
.fn()
.mockRejectedValue(new Error('Callback error'))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
await service.registerInitCallback(mockCallback)
expect(consoleSpy).toHaveBeenCalledWith(
'New user initialization callback failed:',
expect.any(Error)
)
consoleSpy.mockRestore()
})
})
describe('initializeIfNewUser', () => {
it('should set installed version for new users', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.InstalledVersion',
'1.24.0'
)
})
it('should not set installed version for existing users', async () => {
mockSettingStore.settingValues = { 'some.setting': 'value' }
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return true
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(mockSettingStore.set).not.toHaveBeenCalled()
})
it('should execute pending callbacks for new users', async () => {
const mockCallback1 = vi.fn().mockResolvedValue(undefined)
const mockCallback2 = vi.fn().mockResolvedValue(undefined)
await service.registerInitCallback(mockCallback1)
await service.registerInitCallback(mockCallback2)
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(mockCallback1).toHaveBeenCalledTimes(1)
expect(mockCallback2).toHaveBeenCalledTimes(1)
})
it('should not execute pending callbacks for existing users', async () => {
const mockCallback = vi.fn().mockResolvedValue(undefined)
await service.registerInitCallback(mockCallback)
mockSettingStore.settingValues = { 'some.setting': 'value' }
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return true
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(mockCallback).not.toHaveBeenCalled()
})
it('should handle callback errors during initialization', async () => {
const mockCallback = vi.fn().mockRejectedValue(new Error('Init error'))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await service.registerInitCallback(mockCallback)
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(consoleSpy).toHaveBeenCalledWith(
'New user initialization callback failed:',
expect.any(Error)
)
consoleSpy.mockRestore()
})
it('should not reinitialize if already determined', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(mockSettingStore.set).toHaveBeenCalledTimes(1)
await service.initializeIfNewUser(mockSettingStore)
expect(mockSettingStore.set).toHaveBeenCalledTimes(1)
})
it('should correctly determine new user status', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
// Before initialization, isNewUser should return null
expect(service.isNewUser()).toBeNull()
await service.initializeIfNewUser(mockSettingStore)
// After initialization, isNewUser should return true for a new user
expect(service.isNewUser()).toBe(true)
// Should set the installed version for new users
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.InstalledVersion',
expect.any(String)
)
})
})
describe('isNewUser', () => {
it('should return null before determination', () => {
expect(service.isNewUser()).toBeNull()
})
it('should return cached result after determination', async () => {
mockSettingStore.settingValues = {}
mockSettingStore.get.mockReturnValue(undefined)
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(true)
})
})
describe('edge cases', () => {
it('should handle settingStore.get returning false as not completed', async () => {
mockSettingStore.settingValues = { 'Comfy.TutorialCompleted': false }
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return false
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
expect(service.isNewUser()).toBe(true)
})
it('should handle multiple callback registrations after initialization', async () => {
const mockCallback1 = vi.fn().mockResolvedValue(undefined)
const mockCallback2 = vi.fn().mockResolvedValue(undefined)
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service.initializeIfNewUser(mockSettingStore)
await service.registerInitCallback(mockCallback1)
await service.registerInitCallback(mockCallback2)
expect(mockCallback1).toHaveBeenCalledTimes(1)
expect(mockCallback2).toHaveBeenCalledTimes(1)
})
})
describe('state sharing between instances', () => {
it('should share state between multiple service instances', async () => {
const service1 = newUserService()
const service2 = newUserService()
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service1.initializeIfNewUser(mockSettingStore)
expect(service2.isNewUser()).toBe(true)
expect(service1.isNewUser()).toBe(service2.isNewUser())
})
it('should execute callbacks registered on different instances', async () => {
const service1 = newUserService()
const service2 = newUserService()
const mockCallback1 = vi.fn().mockResolvedValue(undefined)
const mockCallback2 = vi.fn().mockResolvedValue(undefined)
await service1.registerInitCallback(mockCallback1)
await service2.registerInitCallback(mockCallback2)
mockSettingStore.settingValues = {}
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.TutorialCompleted') return undefined
return undefined
})
mockLocalStorage.getItem.mockReturnValue(null)
await service1.initializeIfNewUser(mockSettingStore)
expect(mockCallback1).toHaveBeenCalledTimes(1)
expect(mockCallback2).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -0,0 +1,330 @@
import { describe, expect, it } from 'vitest'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
describe('nodeOrganizationService', () => {
const createMockNodeDef = (overrides: any = {}) => {
const mockNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test/subcategory',
python_module: 'custom_nodes.MyPackage.nodes',
api_node: false,
nodeSource: {
type: NodeSourceType.CustomNodes,
className: 'comfy-custom',
displayText: 'Custom',
badgeText: 'C'
},
...overrides
}
Object.setPrototypeOf(mockNodeDef, ComfyNodeDefImpl.prototype)
return mockNodeDef as ComfyNodeDefImpl
}
describe('getGroupingStrategies', () => {
it('should return all grouping strategies', () => {
const strategies = nodeOrganizationService.getGroupingStrategies()
expect(strategies).toHaveLength(3)
expect(strategies.map((s) => s.id)).toEqual([
'category',
'module',
'source'
])
})
it('should return immutable copy', () => {
const strategies1 = nodeOrganizationService.getGroupingStrategies()
const strategies2 = nodeOrganizationService.getGroupingStrategies()
expect(strategies1).not.toBe(strategies2)
expect(strategies1).toEqual(strategies2)
})
})
describe('getGroupingStrategy', () => {
it('should return strategy by id', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
expect(strategy).toBeDefined()
expect(strategy?.id).toBe('category')
})
it('should return undefined for unknown id', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('unknown')
expect(strategy).toBeUndefined()
})
})
describe('getSortingStrategies', () => {
it('should return all sorting strategies', () => {
const strategies = nodeOrganizationService.getSortingStrategies()
expect(strategies).toHaveLength(2)
expect(strategies.map((s) => s.id)).toEqual(['original', 'alphabetical'])
})
})
describe('getSortingStrategy', () => {
it('should return strategy by id', () => {
const strategy =
nodeOrganizationService.getSortingStrategy('alphabetical')
expect(strategy).toBeDefined()
expect(strategy?.id).toBe('alphabetical')
})
it('should return undefined for unknown id', () => {
const strategy = nodeOrganizationService.getSortingStrategy('unknown')
expect(strategy).toBeUndefined()
})
})
describe('organizeNodes', () => {
const mockNodes = [
createMockNodeDef({ name: 'NodeA', display_name: 'Zebra Node' }),
createMockNodeDef({ name: 'NodeB', display_name: 'Apple Node' })
]
it('should organize nodes with default options', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes)
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should organize nodes with custom grouping', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
groupBy: 'module'
})
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should organize nodes with custom sorting', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
sortBy: 'alphabetical'
})
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should throw error for unknown grouping strategy', () => {
expect(() => {
nodeOrganizationService.organizeNodes(mockNodes, {
groupBy: 'unknown'
})
}).toThrow('Unknown grouping strategy: unknown')
})
it('should throw error for unknown sorting strategy', () => {
expect(() => {
nodeOrganizationService.organizeNodes(mockNodes, {
sortBy: 'unknown'
})
}).toThrow('Unknown sorting strategy: unknown')
})
})
describe('getGroupingIcon', () => {
it('should return strategy icon', () => {
const icon = nodeOrganizationService.getGroupingIcon('category')
expect(icon).toBe('pi pi-folder')
})
it('should return fallback icon for unknown strategy', () => {
const icon = nodeOrganizationService.getGroupingIcon('unknown')
expect(icon).toBe('pi pi-sort')
})
})
describe('getSortingIcon', () => {
it('should return strategy icon', () => {
const icon = nodeOrganizationService.getSortingIcon('alphabetical')
expect(icon).toBe('pi pi-sort-alpha-down')
})
it('should return fallback icon for unknown strategy', () => {
const icon = nodeOrganizationService.getSortingIcon('unknown')
expect(icon).toBe('pi pi-sort')
})
})
describe('grouping path extraction', () => {
const mockNodeDef = createMockNodeDef()
it('category grouping should use category path', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['test', 'subcategory', 'TestNode'])
})
it('module grouping should extract module path', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('module')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['MyPackage', 'TestNode'])
})
it('source grouping should categorize by source type', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('source')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['Custom nodes', 'TestNode'])
})
})
describe('edge cases', () => {
describe('module grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('module')
it('should handle empty python_module', () => {
const nodeDef = createMockNodeDef({ python_module: '' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['unknown_module', 'TestNode'])
})
it('should handle undefined python_module', () => {
const nodeDef = createMockNodeDef({ python_module: undefined })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['unknown_module', 'TestNode'])
})
it('should handle modules with spaces in the name', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.My Package With Spaces.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['My Package With Spaces', 'TestNode'])
})
it('should handle modules with special characters', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.my-package_v2.0.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['my-package_v2', 'TestNode'])
})
it('should handle deeply nested modules', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.package.subpackage.module.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['package', 'TestNode'])
})
it('should handle core nodes module path', () => {
const nodeDef = createMockNodeDef({ python_module: 'nodes' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['core', 'TestNode'])
})
it('should handle non-standard module paths', () => {
const nodeDef = createMockNodeDef({
python_module: 'some.other.module.path'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['some', 'other', 'module', 'path', 'TestNode'])
})
})
describe('category grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
it('should handle empty category', () => {
const nodeDef = createMockNodeDef({ category: '' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['TestNode'])
})
it('should handle undefined category', () => {
const nodeDef = createMockNodeDef({ category: undefined })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['TestNode'])
})
it('should handle category with trailing slash', () => {
const nodeDef = createMockNodeDef({ category: 'test/subcategory/' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', 'subcategory', '', 'TestNode'])
})
it('should handle category with multiple consecutive slashes', () => {
const nodeDef = createMockNodeDef({ category: 'test//subcategory' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', '', 'subcategory', 'TestNode'])
})
})
describe('source grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('source')
it('should handle API nodes', () => {
const nodeDef = createMockNodeDef({
api_node: true,
nodeSource: {
type: NodeSourceType.Core,
className: 'comfy-core',
displayText: 'Core',
badgeText: 'C'
}
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['API nodes', 'TestNode'])
})
it('should handle unknown source type', () => {
const nodeDef = createMockNodeDef({
nodeSource: {
type: 'unknown' as any,
className: 'unknown',
displayText: 'Unknown',
badgeText: '?'
}
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['Unknown', 'TestNode'])
})
})
describe('node name edge cases', () => {
it('should handle nodes with special characters in name', () => {
const nodeDef = createMockNodeDef({
name: 'Test/Node:With*Special<Chars>'
})
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual([
'test',
'subcategory',
'Test/Node:With*Special<Chars>'
])
})
it('should handle nodes with very long names', () => {
const longName = 'A'.repeat(100)
const nodeDef = createMockNodeDef({ name: longName })
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', 'subcategory', longName])
})
})
})
describe('sorting comparison', () => {
it('original sort should keep order', () => {
const strategy = nodeOrganizationService.getSortingStrategy('original')
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
const nodeB = createMockNodeDef({ display_name: 'Apple' })
expect(strategy?.compare(nodeA, nodeB)).toBe(0)
})
it('alphabetical sort should compare display names', () => {
const strategy =
nodeOrganizationService.getSortingStrategy('alphabetical')
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
const nodeB = createMockNodeDef({ display_name: 'Apple' })
expect(strategy?.compare(nodeA, nodeB)).toBeGreaterThan(0)
expect(strategy?.compare(nodeB, nodeA)).toBeLessThan(0)
})
})
})

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { NodeSearchService } from '@/services/nodeSearchService'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const EXAMPLE_NODE_DEFS: ComfyNodeDefImpl[] = (
[
{
input: {
required: {
ckpt_name: [['model1.safetensors', 'model2.ckpt'], {}]
}
},
output: ['MODEL', 'CLIP', 'VAE'],
output_is_list: [false, false, false],
output_name: ['MODEL', 'CLIP', 'VAE'],
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
description: '',
python_module: 'nodes',
category: 'loaders',
output_node: false
},
{
input: {
required: {
samples: ['LATENT'],
batch_index: [
'INT',
{
default: 0,
min: 0,
max: 63
}
],
length: [
'INT',
{
default: 1,
min: 1,
max: 64
}
]
}
},
output: ['LATENT'],
output_is_list: [false],
output_name: ['LATENT'],
name: 'LatentFromBatch',
display_name: 'Latent From Batch',
description: '',
python_module: 'nodes',
category: 'latent/batch',
output_node: false
}
] as ComfyNodeDef[]
).map((nodeDef: ComfyNodeDef) => {
const def = new ComfyNodeDefImpl(nodeDef)
def['postProcessSearchScores'] = (s) => s
return def
})
describe('nodeSearchService', () => {
it('searches with input filter', () => {
const service = new NodeSearchService(EXAMPLE_NODE_DEFS)
const inputFilter = service.inputTypeFilter
expect(
service.searchNode('L', [{ filterDef: inputFilter, value: 'LATENT' }])
).toHaveLength(1)
// Wildcard should match all.
expect(
service.searchNode('L', [{ filterDef: inputFilter, value: '*' }])
).toHaveLength(2)
expect(service.searchNode('L')).toHaveLength(2)
})
})

View File

@@ -0,0 +1,373 @@
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
import { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes'
// Mock global Algolia constants
;(global as any).__ALGOLIA_APP_ID__ = 'test-app-id'
;(global as any).__ALGOLIA_API_KEY__ = 'test-api-key'
// Mock algoliasearch
vi.mock('algoliasearch/dist/lite/builds/browser', () => ({
liteClient: vi.fn()
}))
describe('useAlgoliaSearchProvider', () => {
let mockSearchClient: any
beforeEach(() => {
vi.clearAllMocks()
// Create mock search client
mockSearchClient = {
search: vi.fn()
}
vi.mocked(algoliasearch).mockReturnValue(mockSearchClient)
})
afterEach(() => {
// Clear the module-level cache between tests
const provider = useAlgoliaSearchProvider()
provider.clearSearchCache()
})
describe('searchPacks', () => {
it('should search for packs and convert results', async () => {
const mockAlgoliaResults = {
results: [
{
hits: [
{
objectID: 'algolia-1',
id: 'pack-1',
name: 'Test Pack',
description: 'A test pack',
publisher_id: 'publisher-1',
total_install: 500,
create_time: '2024-01-01T00:00:00Z',
update_time: '2024-01-15T00:00:00Z',
repository_url: 'https://github.com/test/pack',
license: 'MIT',
status: 'active',
latest_version: '1.0.0',
latest_version_status: 'published',
icon_url: 'https://example.com/icon.png',
comfy_nodes: ['LoadImage', 'SaveImage']
}
]
},
{ hits: [] } // Query suggestions
]
}
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
const provider = useAlgoliaSearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(mockSearchClient.search).toHaveBeenCalledWith({
requests: [
{
query: 'test',
indexName: 'nodes_index',
attributesToRetrieve: expect.any(Array),
hitsPerPage: 10,
page: 0
},
{
query: 'test',
indexName: 'nodes_index_query_suggestions'
}
],
strategy: 'none'
})
expect(result.nodePacks).toHaveLength(1)
expect(result.nodePacks[0]).toEqual({
id: 'pack-1',
name: 'Test Pack',
description: 'A test pack',
repository: 'https://github.com/test/pack',
license: 'MIT',
downloads: 500,
status: 'active',
icon: 'https://example.com/icon.png',
latest_version: {
version: '1.0.0',
createdAt: '2024-01-15T00:00:00Z',
status: 'published',
comfy_node_extract_status: undefined
},
publisher: {
id: 'publisher-1',
name: 'publisher-1'
},
created_at: '2024-01-01T00:00:00Z',
comfy_nodes: ['LoadImage', 'SaveImage'],
category: undefined,
author: undefined,
tags: undefined,
github_stars: undefined,
supported_os: undefined,
supported_comfyui_version: undefined,
supported_comfyui_frontend_version: undefined,
supported_accelerators: undefined,
banner_url: undefined
})
})
it('should include query suggestions when query is long enough', async () => {
const mockAlgoliaResults = {
results: [
{ hits: [] }, // Main results
{
hits: [
{ query: 'test query', popularity: 10 },
{ query: 'test pack', popularity: 5 }
]
}
]
}
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
const provider = useAlgoliaSearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
// Should make 2 requests (main + suggestions)
expect(mockSearchClient.search).toHaveBeenCalledWith({
requests: [
expect.objectContaining({ indexName: 'nodes_index' }),
expect.objectContaining({
indexName: 'nodes_index_query_suggestions'
})
],
strategy: 'none'
})
expect(result.querySuggestions).toEqual([
{ query: 'test query', popularity: 10 },
{ query: 'test pack', popularity: 5 }
])
})
it('should not query suggestions for short queries', async () => {
mockSearchClient.search.mockResolvedValue({
results: [{ hits: [] }]
})
const provider = useAlgoliaSearchProvider()
await provider.searchPacks('a', {
pageSize: 10,
pageNumber: 0
})
// Should only make 1 request (no suggestions)
expect(mockSearchClient.search).toHaveBeenCalledWith({
requests: [expect.objectContaining({ indexName: 'nodes_index' })],
strategy: 'none'
})
})
it('should cache search results', async () => {
mockSearchClient.search.mockResolvedValue({
results: [{ hits: [] }, { hits: [] }]
})
const provider = useAlgoliaSearchProvider()
const params = { pageSize: 10, pageNumber: 0 }
// First call
await provider.searchPacks('test', params)
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
// Second call with same params should use cache
await provider.searchPacks('test', params)
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
// Different params should make new request
await provider.searchPacks('test', { ...params, pageNumber: 1 })
expect(mockSearchClient.search).toHaveBeenCalledTimes(2)
})
it('should handle missing objectID by using id field', async () => {
const mockAlgoliaResults = {
results: [
{
hits: [
{
id: 'pack-id-only',
name: 'Pack without objectID',
// ... other required fields
publisher_id: 'pub',
total_install: 0,
comfy_nodes: []
}
]
},
{ hits: [] }
]
}
mockSearchClient.search.mockResolvedValue(mockAlgoliaResults)
const provider = useAlgoliaSearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(result.nodePacks[0].id).toBe('pack-id-only')
})
})
describe('clearSearchCache', () => {
it('should clear the cache', async () => {
mockSearchClient.search.mockResolvedValue({
results: [{ hits: [] }, { hits: [] }]
})
const provider = useAlgoliaSearchProvider()
const params = { pageSize: 10, pageNumber: 0 }
// Populate cache
await provider.searchPacks('test', params)
expect(mockSearchClient.search).toHaveBeenCalledTimes(1)
// Clear cache
provider.clearSearchCache()
// Same search should hit API again
await provider.searchPacks('test', params)
expect(mockSearchClient.search).toHaveBeenCalledTimes(2)
})
})
describe('getSortValue', () => {
const testPack = {
id: '1',
name: 'Test Pack',
downloads: 100,
publisher: { id: 'pub1', name: 'Publisher One' },
latest_version: {
version: '1.0.0',
createdAt: '2024-01-15T10:00:00Z'
},
created_at: '2024-01-01T10:00:00Z'
}
it('should return correct values for each sort field', () => {
const provider = useAlgoliaSearchProvider()
expect(
provider.getSortValue(testPack, SortableAlgoliaField.Downloads)
).toBe(100)
expect(provider.getSortValue(testPack, SortableAlgoliaField.Name)).toBe(
'Test Pack'
)
expect(
provider.getSortValue(testPack, SortableAlgoliaField.Publisher)
).toBe('Publisher One')
const createdTimestamp = new Date('2024-01-01T10:00:00Z').getTime()
expect(
provider.getSortValue(testPack as any, SortableAlgoliaField.Created)
).toBe(createdTimestamp)
const updatedTimestamp = new Date('2024-01-15T10:00:00Z').getTime()
expect(
provider.getSortValue(testPack, SortableAlgoliaField.Updated)
).toBe(updatedTimestamp)
})
it('should handle missing values', () => {
const incompletePack = { id: '1', name: 'Incomplete' }
const provider = useAlgoliaSearchProvider()
expect(
provider.getSortValue(incompletePack, SortableAlgoliaField.Downloads)
).toBe(0)
expect(
provider.getSortValue(incompletePack, SortableAlgoliaField.Publisher)
).toBe('')
expect(
provider.getSortValue(
incompletePack as any,
SortableAlgoliaField.Created
)
).toBe(0)
expect(
provider.getSortValue(incompletePack, SortableAlgoliaField.Updated)
).toBe(0)
})
})
describe('getSortableFields', () => {
it('should return all Algolia sort fields', () => {
const provider = useAlgoliaSearchProvider()
const fields = provider.getSortableFields()
expect(fields).toEqual([
{
id: SortableAlgoliaField.Downloads,
label: 'Downloads',
direction: 'desc'
},
{
id: SortableAlgoliaField.Created,
label: 'Created',
direction: 'desc'
},
{
id: SortableAlgoliaField.Updated,
label: 'Updated',
direction: 'desc'
},
{
id: SortableAlgoliaField.Publisher,
label: 'Publisher',
direction: 'asc'
},
{ id: SortableAlgoliaField.Name, label: 'Name', direction: 'asc' }
])
})
})
describe('memoization', () => {
it('should memoize toRegistryPack conversions', async () => {
const mockHit = {
objectID: 'algolia-1',
id: 'pack-1',
name: 'Test Pack',
publisher_id: 'pub1',
total_install: 100,
comfy_nodes: []
}
mockSearchClient.search.mockResolvedValue({
results: [
{ hits: [mockHit, mockHit, mockHit] }, // Same object 3 times
{ hits: [] }
]
})
const provider = useAlgoliaSearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
// All 3 results should be the same object reference due to memoization
expect(result.nodePacks[0]).toBe(result.nodePacks[1])
expect(result.nodePacks[1]).toBe(result.nodePacks[2])
})
})
})

View File

@@ -0,0 +1,186 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useComfyRegistrySearchProvider } from '@/services/providers/registrySearchProvider'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
// Mock the store
vi.mock('@/stores/comfyRegistryStore', () => ({
useComfyRegistryStore: vi.fn()
}))
describe('useComfyRegistrySearchProvider', () => {
const mockSearchCall = vi.fn()
const mockSearchClear = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Setup store mock
vi.mocked(useComfyRegistryStore).mockReturnValue({
search: {
call: mockSearchCall,
clear: mockSearchClear
}
} as any)
})
describe('searchPacks', () => {
it('should search for packs by name', async () => {
const mockResults = {
nodes: [
{ id: '1', name: 'Test Pack 1' },
{ id: '2', name: 'Test Pack 2' }
]
}
mockSearchCall.mockResolvedValue(mockResults)
const provider = useComfyRegistrySearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0,
restrictSearchableAttributes: ['name', 'description']
})
expect(mockSearchCall).toHaveBeenCalledWith({
search: 'test',
comfy_node_search: undefined,
limit: 10,
page: 1
})
expect(result.nodePacks).toEqual(mockResults.nodes)
expect(result.querySuggestions).toEqual([])
})
it('should search for packs by node names', async () => {
const mockResults = {
nodes: [{ id: '1', name: 'Pack with LoadImage node' }]
}
mockSearchCall.mockResolvedValue(mockResults)
const provider = useComfyRegistrySearchProvider()
const result = await provider.searchPacks('LoadImage', {
pageSize: 20,
pageNumber: 1,
restrictSearchableAttributes: ['comfy_nodes']
})
expect(mockSearchCall).toHaveBeenCalledWith({
search: undefined,
comfy_node_search: 'LoadImage',
limit: 20,
page: 2
})
expect(result.nodePacks).toEqual(mockResults.nodes)
})
it('should handle empty results', async () => {
mockSearchCall.mockResolvedValue({ nodes: [] })
const provider = useComfyRegistrySearchProvider()
const result = await provider.searchPacks('nonexistent', {
pageSize: 10,
pageNumber: 0
})
expect(result.nodePacks).toEqual([])
expect(result.querySuggestions).toEqual([])
})
it('should handle null results', async () => {
mockSearchCall.mockResolvedValue(null)
const provider = useComfyRegistrySearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(result.nodePacks).toEqual([])
expect(result.querySuggestions).toEqual([])
})
it('should handle results without nodes property', async () => {
mockSearchCall.mockResolvedValue({})
const provider = useComfyRegistrySearchProvider()
const result = await provider.searchPacks('test', {
pageSize: 10,
pageNumber: 0
})
expect(result.nodePacks).toEqual([])
expect(result.querySuggestions).toEqual([])
})
})
describe('clearSearchCache', () => {
it('should delegate to store search.clear', () => {
const provider = useComfyRegistrySearchProvider()
provider.clearSearchCache()
expect(mockSearchClear).toHaveBeenCalled()
})
})
describe('getSortValue', () => {
const testPack = {
id: '1',
name: 'Test Pack',
downloads: 100,
publisher: { id: 'pub1', name: 'Publisher One' },
latest_version: {
version: '1.0.0',
createdAt: '2024-01-15T10:00:00Z'
}
}
it('should return download count for downloads field', () => {
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(testPack, 'downloads')).toBe(100)
})
it('should return pack name for name field', () => {
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(testPack, 'name')).toBe('Test Pack')
})
it('should return publisher name for publisher field', () => {
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(testPack, 'publisher')).toBe('Publisher One')
})
it('should return timestamp for updated field', () => {
const provider = useComfyRegistrySearchProvider()
const timestamp = new Date('2024-01-15T10:00:00Z').getTime()
expect(provider.getSortValue(testPack, 'updated')).toBe(timestamp)
})
it('should handle missing values gracefully', () => {
const incompletePack = { id: '1', name: 'Incomplete' }
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(incompletePack, 'downloads')).toBe(0)
expect(provider.getSortValue(incompletePack, 'publisher')).toBe('')
expect(provider.getSortValue(incompletePack, 'updated')).toBe(0)
})
it('should return 0 for unknown sort fields', () => {
const provider = useComfyRegistrySearchProvider()
expect(provider.getSortValue(testPack, 'unknown')).toBe(0)
})
})
describe('getSortableFields', () => {
it('should return supported sort fields', () => {
const provider = useComfyRegistrySearchProvider()
const fields = provider.getSortableFields()
expect(fields).toEqual([
{ id: 'downloads', label: 'Downloads', direction: 'desc' },
{ id: 'name', label: 'Name', direction: 'asc' },
{ id: 'publisher', label: 'Publisher', direction: 'asc' },
{ id: 'updated', label: 'Updated', direction: 'desc' }
])
})
})
})