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,486 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import ManagerProgressFooter from '@/workbench/extensions/manager/components/ManagerProgressFooter.vue'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { TaskLog } from '@/workbench/extensions/manager/types/comfyManagerTypes'
// Mock modules
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore')
vi.mock('@/stores/dialogStore')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/commandStore')
vi.mock('@/workbench/extensions/manager/services/comfyManagerService')
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: vi.fn(() => ({
conflictedPackages: { value: [] },
runFullConflictAnalysis: vi.fn().mockResolvedValue(undefined)
}))
})
)
// Mock useEventListener to capture the event handler
let reconnectHandler: (() => void) | null = null
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
useEventListener: vi.fn(
(_target: any, event: string, handler: any, _options: any) => {
if (event === 'reconnected') {
reconnectHandler = handler
}
}
)
}
})
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => ({
reloadCurrentWorkflow: vi.fn().mockResolvedValue(undefined)
}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({
completedActivePalette: {
light_theme: false
}
}))
}))
// Helper function to mount component with required setup
const mountComponent = (options: { captureError?: boolean } = {}) => {
const pinia = createPinia()
setActivePinia(pinia)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
close: 'Close',
progressCountOf: 'of'
},
contextMenu: {
Collapse: 'Collapse',
Expand: 'Expand'
},
manager: {
clickToFinishSetup: 'Click',
applyChanges: 'Apply Changes',
toFinishSetup: 'to finish setup',
restartingBackend: 'Restarting backend to apply changes...',
extensionsSuccessfullyInstalled:
'Extension(s) successfully installed and are ready to use!',
restartToApplyChanges: 'To apply changes, please restart ComfyUI',
installingDependencies: 'Installing dependencies...'
}
}
}
})
const config: any = {
global: {
plugins: [pinia, PrimeVue, i18n]
}
}
// Add error handler for tests that expect errors
if (options.captureError) {
config.global.config = {
errorHandler: () => {
// Suppress error in test
}
}
}
return mount(ManagerProgressFooter, config)
}
describe('ManagerProgressFooter', () => {
const mockTaskLogs: TaskLog[] = []
const mockComfyManagerStore = {
taskLogs: mockTaskLogs,
allTasksDone: true,
isProcessingTasks: false,
succeededTasksIds: [] as string[],
failedTasksIds: [] as string[],
taskHistory: {} as Record<string, any>,
taskQueue: null,
resetTaskState: vi.fn(),
clearLogs: vi.fn(),
setStale: vi.fn(),
// Add other required properties
isLoading: { value: false },
error: { value: null },
statusMessage: { value: 'DONE' },
installedPacks: {},
installedPacksIds: new Set(),
isPackInstalled: vi.fn(),
isPackEnabled: vi.fn(),
getInstalledPackVersion: vi.fn(),
refreshInstalledList: vi.fn(),
installPack: vi.fn(),
uninstallPack: vi.fn(),
updatePack: vi.fn(),
updateAllPacks: vi.fn(),
disablePack: vi.fn(),
enablePack: vi.fn()
}
const mockDialogStore = {
closeDialog: vi.fn(),
// Add other required properties
dialogStack: { value: [] },
showDialog: vi.fn(),
$id: 'dialog',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
$onAction: vi.fn()
}
const mockSettingStore = {
get: vi.fn().mockReturnValue(false),
set: vi.fn(),
// Add other required properties
settingValues: { value: {} },
settingsById: { value: {} },
exists: vi.fn(),
getDefaultValue: vi.fn(),
loadSettingValues: vi.fn(),
updateValue: vi.fn(),
$id: 'setting',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
$onAction: vi.fn()
}
const mockProgressDialogStore = {
isExpanded: false,
toggle: vi.fn(),
collapse: vi.fn(),
expand: vi.fn()
}
const mockCommandStore = {
execute: vi.fn().mockResolvedValue(undefined)
}
const mockComfyManagerService = {
rebootComfyUI: vi.fn().mockResolvedValue(null)
}
beforeEach(() => {
vi.clearAllMocks()
// Create new pinia instance for each test
const pinia = createPinia()
setActivePinia(pinia)
// Reset task logs
mockTaskLogs.length = 0
mockComfyManagerStore.taskLogs = mockTaskLogs
// Reset event handler
reconnectHandler = null
vi.mocked(useComfyManagerStore).mockReturnValue(
mockComfyManagerStore as any
)
vi.mocked(useDialogStore).mockReturnValue(mockDialogStore as any)
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useManagerProgressDialogStore).mockReturnValue(
mockProgressDialogStore as any
)
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
vi.mocked(useComfyManagerService).mockReturnValue(
mockComfyManagerService as any
)
})
describe('State 1: Queue Running', () => {
it('should display loading spinner and progress counter when queue is running', async () => {
// Setup queue running state
mockComfyManagerStore.isProcessingTasks = true
mockComfyManagerStore.succeededTasksIds = ['1', '2']
mockComfyManagerStore.failedTasksIds = []
mockComfyManagerStore.taskHistory = {
'1': { taskName: 'Installing pack1' },
'2': { taskName: 'Installing pack2' },
'3': { taskName: 'Installing pack3' }
}
mockTaskLogs.push(
{ taskName: 'Installing pack1', taskId: '1', logs: [] },
{ taskName: 'Installing pack2', taskId: '2', logs: [] },
{ taskName: 'Installing pack3', taskId: '3', logs: [] }
)
const wrapper = mountComponent()
// Check loading spinner exists (DotSpinner component)
expect(wrapper.find('.inline-flex').exists()).toBe(true)
// Check current task name is displayed
expect(wrapper.text()).toContain('Installing pack3')
// Check progress counter (completed: 2 of 3)
expect(wrapper.text()).toMatch(/2.*of.*3/)
// Check expand/collapse button exists
const expandButton = wrapper.find('[aria-label="Expand"]')
expect(expandButton.exists()).toBe(true)
// Check Apply Changes button is NOT shown
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('should toggle expansion when expand button is clicked', async () => {
mockComfyManagerStore.isProcessingTasks = true
mockTaskLogs.push({ taskName: 'Installing', taskId: '1', logs: [] })
const wrapper = mountComponent()
const expandButton = wrapper.find('[aria-label="Expand"]')
await expandButton.trigger('click')
expect(mockProgressDialogStore.toggle).toHaveBeenCalled()
})
})
describe('State 2: Tasks Completed (Waiting for Restart)', () => {
it('should display check mark and Apply Changes button when all tasks are done', async () => {
// Setup tasks completed state
mockComfyManagerStore.isProcessingTasks = false
mockTaskLogs.push(
{ taskName: 'Installed pack1', taskId: '1', logs: [] },
{ taskName: 'Installed pack2', taskId: '2', logs: [] }
)
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Check check mark emoji
expect(wrapper.text()).toContain('✅')
// Check restart message
expect(wrapper.text()).toContain(
'To apply changes, please restart ComfyUI'
)
expect(wrapper.text()).toContain('Apply Changes')
// Check Apply Changes button exists
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
expect(applyButton).toBeTruthy()
// Check no progress counter
expect(wrapper.text()).not.toMatch(/\d+.*of.*\d+/)
})
})
describe('State 3: Restarting', () => {
it('should display restarting message and spinner during restart', async () => {
// Setup completed state first
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Click Apply Changes to trigger restart
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for state update
await nextTick()
// Check restarting message
expect(wrapper.text()).toContain('Restarting backend to apply changes...')
// Check loading spinner during restart
expect(wrapper.find('.inline-flex').exists()).toBe(true)
// Check Apply Changes button is hidden
expect(wrapper.text()).not.toContain('Apply Changes')
})
})
describe('State 4: Restart Completed', () => {
it('should display success message and auto-close after 3 seconds', async () => {
vi.useFakeTimers()
// Setup completed state
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Trigger restart
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for event listener to be set up
await nextTick()
// Trigger the reconnect handler directly
if (reconnectHandler) {
await reconnectHandler()
}
// Wait for restart completed state
await nextTick()
// Check success message
expect(wrapper.text()).toContain('🎉')
expect(wrapper.text()).toContain(
'Extension(s) successfully installed and are ready to use!'
)
// Check dialog closes after 3 seconds
vi.advanceTimersByTime(3000)
await nextTick()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-manager-progress-dialog'
})
expect(mockComfyManagerStore.resetTaskState).toHaveBeenCalled()
vi.useRealTimers()
})
})
describe('Common Features', () => {
it('should always display close button', async () => {
const wrapper = mountComponent()
const closeButton = wrapper.find('[aria-label="Close"]')
expect(closeButton.exists()).toBe(true)
})
it('should close dialog when close button is clicked', async () => {
const wrapper = mountComponent()
const closeButton = wrapper.find('[aria-label="Close"]')
await closeButton.trigger('click')
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-manager-progress-dialog'
})
})
})
describe('Toast Management', () => {
it('should suppress reconnection toasts during restart', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
mockSettingStore.get.mockReturnValue(false) // Original setting
const wrapper = mountComponent()
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Check toast setting was disabled
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
true
)
})
it('should restore toast settings after restart completes', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
mockSettingStore.get.mockReturnValue(false) // Original setting
const wrapper = mountComponent()
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for event listener to be set up
await nextTick()
// Trigger the reconnect handler directly
if (reconnectHandler) {
await reconnectHandler()
}
// Wait for settings restoration
await nextTick()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false // Restored to original
)
})
})
describe('Error Handling', () => {
it('should restore state and close dialog on restart error', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
// Mock restart to throw error
mockComfyManagerService.rebootComfyUI.mockRejectedValue(
new Error('Restart failed')
)
const wrapper = mountComponent({ captureError: true })
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
expect(applyButton).toBeTruthy()
// The component throws the error but Vue Test Utils catches it
// We need to check if the error handling logic was executed
await applyButton!.trigger('click').catch(() => {
// Error is expected, ignore it
})
// Wait for error handling
await nextTick()
// Check dialog was closed on error
expect(mockDialogStore.closeDialog).toHaveBeenCalled()
// Check toast settings were restored
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false
)
// Check that the error handler was called
expect(mockComfyManagerService.rebootComfyUI).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,468 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
// Mock getConflictMessage utility
vi.mock('@/utils/conflictMessageUtil', () => ({
getConflictMessage: vi.fn((conflict) => {
return `${conflict.type}: ${conflict.current_value} vs ${conflict.required_value}`
})
}))
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'manager.conflicts.description': 'Some extensions are not compatible',
'manager.conflicts.info': 'Additional info about conflicts',
'manager.conflicts.conflicts': 'Conflicts',
'manager.conflicts.extensionAtRisk': 'Extensions at Risk',
'manager.conflicts.importFailedExtensions': 'Import Failed Extensions'
}
return translations[key] || key
})
}))
}))
// Mock data for conflict detection
const mockConflictData = ref<ConflictDetectionResult[]>([])
// Mock useConflictDetection composable
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: () => ({
conflictedPackages: computed(() => mockConflictData.value)
})
})
)
describe('NodeConflictDialogContent', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.clearAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Reset mock data
mockConflictData.value = []
})
const createWrapper = (props = {}) => {
return mount(NodeConflictDialogContent, {
props,
global: {
plugins: [pinia],
components: {
Button
},
stubs: {
ContentDivider: true
},
mocks: {
$t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'manager.conflicts.description':
'Some extensions are not compatible',
'manager.conflicts.info': 'Additional info about conflicts',
'manager.conflicts.conflicts': 'Conflicts',
'manager.conflicts.extensionAtRisk': 'Extensions at Risk',
'manager.conflicts.importFailedExtensions':
'Import Failed Extensions'
}
return translations[key] || key
})
}
}
})
}
const mockConflictResults: ConflictDetectionResult[] = [
{
package_id: 'Package1',
package_name: 'Test Package 1',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'os',
current_value: 'macOS',
required_value: 'Windows'
},
{
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA'
}
]
},
{
package_id: 'Package2',
package_name: 'Test Package 2',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
]
},
{
package_id: 'Package3',
package_name: 'Test Package 3',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'import_failed',
current_value: 'installed',
required_value: 'ModuleNotFoundError: No module named "example"'
}
]
}
]
describe('rendering', () => {
it('should render without conflicts', () => {
// Set empty conflict data
mockConflictData.value = []
const wrapper = createWrapper()
// When there are no conflicts, the conflict sections should not be rendered
expect(wrapper.text()).not.toContain('Conflicts')
expect(wrapper.text()).not.toContain('Extensions at Risk')
expect(wrapper.find('[class*="Import Failed Extensions"]').exists()).toBe(
false
)
})
it('should render with conflict data from composable', () => {
// Set conflict data
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Should show 3 total conflicts (2 from Package1 + 1 from Package2, excluding import_failed)
expect(wrapper.text()).toContain('3')
expect(wrapper.text()).toContain('Conflicts')
// Should show 3 extensions at risk (all packages)
expect(wrapper.text()).toContain('Extensions at Risk')
// Should show import failed section
expect(wrapper.text()).toContain('Import Failed Extensions')
expect(wrapper.text()).toContain('1') // 1 import failed package
})
it('should show description when showAfterWhatsNew is true', () => {
const wrapper = createWrapper({
showAfterWhatsNew: true
})
expect(wrapper.text()).toContain('Some extensions are not compatible')
expect(wrapper.text()).toContain('Additional info about conflicts')
})
it('should not show description when showAfterWhatsNew is false', () => {
const wrapper = createWrapper({
showAfterWhatsNew: false
})
expect(wrapper.text()).not.toContain('Some extensions are not compatible')
expect(wrapper.text()).not.toContain('Additional info about conflicts')
})
it('should separate import_failed conflicts into separate section', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Import Failed Extensions section should show 1 package
const importFailedSection = wrapper.findAll(
'.w-full.flex.flex-col.bg-base-background'
)[0]
expect(importFailedSection.text()).toContain('1')
expect(importFailedSection.text()).toContain('Import Failed Extensions')
// Conflicts section should show 3 conflicts (excluding import_failed)
const conflictsSection = wrapper.findAll(
'.w-full.flex.flex-col.bg-base-background'
)[1]
expect(conflictsSection.text()).toContain('3')
expect(conflictsSection.text()).toContain('Conflicts')
})
})
describe('panel interactions', () => {
beforeEach(() => {
mockConflictData.value = mockConflictResults
})
it('should toggle import failed panel', async () => {
const wrapper = createWrapper()
// Find import failed panel header (first one)
const importFailedHeader = wrapper.find(
'[data-testid="conflict-dialog-panel-toggle"]'
)
// Initially collapsed
expect(
wrapper.find('[data-testid="conflict-dialog-panel-expanded"]').exists()
).toBe(false)
// Click to expand import failed panel
await importFailedHeader.trigger('click')
// Should be expanded now and show package name
const expandedContent = wrapper.find(
'[data-testid="conflict-dialog-panel-expanded"]'
)
expect(expandedContent.exists()).toBe(true)
expect(expandedContent.text()).toContain('Test Package 3')
// Should show chevron-down icon when expanded
const chevronButton = wrapper.findComponent(Button)
expect(chevronButton.find('i').classes()).toContain('pi-chevron-down')
})
it('should toggle conflicts panel', async () => {
const wrapper = createWrapper()
// Find conflicts panel header (second one)
const conflictsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[1]
// Click to expand conflicts panel
await conflictsHeader.trigger('click')
// Should be expanded now
const conflictItems = wrapper.findAll('.conflict-list-item')
expect(conflictItems.length).toBeGreaterThan(0)
})
it('should toggle extensions panel', async () => {
const wrapper = createWrapper()
// Find extensions panel header (third one)
const extensionsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[2]
// Click to expand extensions panel
await extensionsHeader.trigger('click')
// Should be expanded now and show all package names
const expandedContent = wrapper.findAll(
'[data-testid="conflict-dialog-panel-expanded"]'
)[0]
expect(expandedContent.exists()).toBe(true)
expect(expandedContent.text()).toContain('Test Package 1')
expect(expandedContent.text()).toContain('Test Package 2')
expect(expandedContent.text()).toContain('Test Package 3')
})
it('should collapse other panels when opening one', async () => {
const wrapper = createWrapper()
const importFailedHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[0]
const conflictsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[1]
const extensionsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[2]
// Open import failed panel first
await importFailedHeader.trigger('click')
// Verify import failed panel is open
expect((wrapper.vm as any).importFailedExpanded).toBe(true)
expect((wrapper.vm as any).conflictsExpanded).toBe(false)
expect((wrapper.vm as any).extensionsExpanded).toBe(false)
// Open conflicts panel
await conflictsHeader.trigger('click')
// Verify conflicts panel is open and others are closed
expect((wrapper.vm as any).importFailedExpanded).toBe(false)
expect((wrapper.vm as any).conflictsExpanded).toBe(true)
expect((wrapper.vm as any).extensionsExpanded).toBe(false)
// Open extensions panel
await extensionsHeader.trigger('click')
// Verify extensions panel is open and others are closed
expect((wrapper.vm as any).importFailedExpanded).toBe(false)
expect((wrapper.vm as any).conflictsExpanded).toBe(false)
expect((wrapper.vm as any).extensionsExpanded).toBe(true)
})
})
describe('conflict display', () => {
beforeEach(() => {
mockConflictData.value = mockConflictResults
})
it('should display individual conflict details excluding import_failed', async () => {
const wrapper = createWrapper()
// Expand conflicts panel (second header)
const conflictsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[1]
await conflictsHeader.trigger('click')
// Should display conflict messages (excluding import_failed)
const conflictItems = wrapper.findAll('.conflict-list-item')
expect(conflictItems).toHaveLength(3) // 2 from Package1 + 1 from Package2
})
it('should display import failed packages separately', async () => {
const wrapper = createWrapper()
// Expand import failed panel (first header)
const importFailedHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[0]
await importFailedHeader.trigger('click')
// Should display only import failed package
const importFailedItems = wrapper.findAll('.conflict-list-item')
expect(importFailedItems).toHaveLength(1)
expect(importFailedItems[0].text()).toContain('Test Package 3')
})
it('should display all package names in extensions list', async () => {
const wrapper = createWrapper()
// Expand extensions panel (third header)
const extensionsHeader = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)[2]
await extensionsHeader.trigger('click')
// Should display all package names
expect(wrapper.text()).toContain('Test Package 1')
expect(wrapper.text()).toContain('Test Package 2')
expect(wrapper.text()).toContain('Test Package 3')
})
})
describe('empty states', () => {
it('should handle empty conflicts gracefully', () => {
mockConflictData.value = []
const wrapper = createWrapper()
// When there are no conflicts, none of the sections should be visible
expect(wrapper.text()).not.toContain('Conflicts')
expect(wrapper.text()).not.toContain('Extensions at Risk')
// Import failed section should not be visible when there are no import failures
expect(wrapper.text()).not.toContain('Import Failed Extensions')
})
it('should handle conflicts without import_failed', () => {
// Only set packages without import_failed conflicts
mockConflictData.value = [mockConflictResults[0], mockConflictResults[1]]
const wrapper = createWrapper()
expect(wrapper.text()).toContain('3') // conflicts count
expect(wrapper.text()).toContain('2') // extensions count
// Import failed section should not be visible
expect(wrapper.text()).not.toContain('Import Failed Extensions')
})
})
describe('scrolling behavior', () => {
it('should apply scrollbar styles to all expandable lists', async () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Test all three panels
const headers = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)
for (let i = 0; i < headers.length; i++) {
await headers[i].trigger('click')
// Check for scrollable container with proper classes
const scrollableContainer = wrapper.find(
'[class*="max-h-"][class*="overflow-y-auto"][class*="scrollbar-hide"]'
)
expect(scrollableContainer.exists()).toBe(true)
// Close the panel for next iteration
await headers[i].trigger('click')
}
})
})
describe('accessibility', () => {
it('should have proper button roles and labels', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
const buttons = wrapper.findAllComponents(Button)
expect(buttons.length).toBe(3) // 3 chevron buttons
// Check chevron buttons have icons
buttons.forEach((button) => {
expect(
button
.find('i')
.classes()
.some((c) => /pi-chevron-(right|down)/.test(c))
).toBe(true)
})
})
it('should have clickable panel headers', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
const headers = wrapper.findAll(
'[data-testid="conflict-dialog-panel-toggle"]'
)
expect(headers).toHaveLength(3) // import failed, conflicts and extensions headers
headers.forEach((header) => {
expect(header.element.tagName).toBe('DIV')
})
})
})
describe('es-toolkit optimization', () => {
it('should efficiently filter conflicts using es-toolkit', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Verify that import_failed conflicts are filtered out from main conflicts
const vm = wrapper.vm as any
expect(vm.allConflictDetails).toHaveLength(3) // Should not include import_failed
expect(
vm.allConflictDetails.every((c: any) => c.type !== 'import_failed')
).toBe(true)
})
it('should efficiently extract import failed packages using es-toolkit', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
// Verify that only import_failed packages are extracted
const vm = wrapper.vm as any
expect(vm.importFailedConflicts).toHaveLength(1)
expect(vm.importFailedConflicts[0]).toBe('Test Package 3')
})
})
})

View File

@@ -0,0 +1,211 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PackCard from '@/workbench/extensions/manager/components/manager/packCard/PackCard.vue'
import type {
MergedNodePack,
RegistryPack
} from '@/workbench/extensions/manager/types/comfyManagerTypes'
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
d: vi.fn(() => '2024. 1. 1.'),
t: vi.fn((key: string) => key)
})),
createI18n: vi.fn(() => ({
global: {
t: vi.fn((key: string) => key),
te: vi.fn(() => true)
}
}))
}))
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
isPackInstalled: vi.fn(() => false),
isPackEnabled: vi.fn(() => true),
isPackInstalling: vi.fn(() => false),
installedPacksIds: []
}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({
completedActivePalette: { light_theme: true }
}))
}))
vi.mock('@vueuse/core', async () => {
const { ref } = await import('vue')
return {
whenever: vi.fn(),
useStorage: vi.fn((_key, defaultValue) => {
return ref(defaultValue)
}),
createSharedComposable: vi.fn((fn) => fn)
}
})
vi.mock('@/config', () => ({
default: {
app_version: '1.24.0-1'
}
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn(() => ({
systemStats: {
system: { os: 'Darwin' },
devices: [{ type: 'mps', name: 'Metal' }]
}
}))
}))
describe('PackCard', () => {
let pinia: ReturnType<typeof createPinia>
beforeEach(() => {
vi.clearAllMocks()
pinia = createPinia()
setActivePinia(pinia)
})
const createWrapper = (props: {
nodePack: MergedNodePack | RegistryPack
isSelected?: boolean
}) => {
const wrapper = mount(PackCard, {
props,
global: {
plugins: [pinia],
components: {
Card,
ProgressSpinner
},
stubs: {
PackBanner: true,
PackVersionBadge: true,
PackCardFooter: true
},
mocks: {
$t: vi.fn((key: string) => key)
}
}
})
return wrapper
}
const mockNodePack: RegistryPack = {
id: 'test-package',
name: 'Test Package',
description: 'Test package description',
author: 'Test Author',
latest_version: {
createdAt: '2024-01-01T00:00:00Z'
}
} as RegistryPack
describe('basic rendering', () => {
it('should render package card with basic information', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.find('.p-card').exists()).toBe(true)
expect(wrapper.text()).toContain('Test Package')
expect(wrapper.text()).toContain('Test package description')
expect(wrapper.text()).toContain('Test Author')
})
it('should render date correctly', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.text()).toContain('2024. 1. 1.')
})
it('should apply selected class when isSelected is true', () => {
const wrapper = createWrapper({
nodePack: mockNodePack,
isSelected: true
})
expect(wrapper.find('.selected-card').exists()).toBe(true)
})
it('should not apply selected class when isSelected is false', () => {
const wrapper = createWrapper({
nodePack: mockNodePack,
isSelected: false
})
expect(wrapper.find('.selected-card').exists()).toBe(false)
})
})
describe('component behavior', () => {
it('should render without errors', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('.p-card').exists()).toBe(true)
})
})
describe('package information display', () => {
it('should display package name', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.text()).toContain('Test Package')
})
it('should display package description', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.text()).toContain('Test package description')
})
it('should display author name', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.text()).toContain('Test Author')
})
it('should handle missing description', () => {
const packWithoutDescription = { ...mockNodePack, description: undefined }
const wrapper = createWrapper({ nodePack: packWithoutDescription })
expect(wrapper.find('p').exists()).toBe(false)
})
it('should handle missing author', () => {
const packWithoutAuthor = { ...mockNodePack, author: undefined }
const wrapper = createWrapper({ nodePack: packWithoutAuthor })
// Should still render without errors
expect(wrapper.exists()).toBe(true)
})
})
describe('component structure', () => {
it('should render PackBanner component', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.find('pack-banner-stub').exists()).toBe(true)
})
it('should render PackVersionBadge component', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.find('pack-version-badge-stub').exists()).toBe(true)
})
it('should render PackCardFooter component', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
expect(wrapper.find('pack-card-footer-stub').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,593 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { LGraphNode, LGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useWorkflowPacks } from '@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
createSharedComposable: <Fn extends (...args: any[]) => any>(fn: Fn) => fn
}
})
// Mock the dependencies
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks',
() => ({
useWorkflowPacks: vi.fn()
})
)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: vi.fn()
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeWorkflow: null
}))
}))
const mockApp: { rootGraph?: Partial<LGraph> } = vi.hoisted(() => ({}))
vi.mock('@/scripts/app', () => ({
app: mockApp
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
collectAllNodes: vi.fn()
}))
const mockUseWorkflowPacks = vi.mocked(useWorkflowPacks)
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
const mockUseNodeDefStore = vi.mocked(useNodeDefStore)
const mockCollectAllNodes = vi.mocked(collectAllNodes)
describe('useMissingNodes', () => {
const mockWorkflowPacks = [
{
id: 'pack-1',
name: 'Test Pack 1',
latest_version: { version: '1.0.0' }
},
{
id: 'pack-2',
name: 'Test Pack 2',
latest_version: { version: '2.0.0' }
},
{
id: 'pack-3',
name: 'Installed Pack',
latest_version: { version: '1.5.0' }
}
]
const mockStartFetchWorkflowPacks = vi.fn()
const mockIsPackInstalled = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Default setup: pack-3 is installed, others are not
mockIsPackInstalled.mockImplementation((id: string) => id === 'pack-3')
// @ts-expect-error - Mocking partial ComfyManagerStore for testing.
// We only need isPackInstalled method for these tests.
mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled
})
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
})
// Reset node def store mock
// @ts-expect-error - Mocking partial NodeDefStore for testing.
// We only need nodeDefsByName for these tests.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
// Reset app.rootGraph.nodes
mockApp.rootGraph = { nodes: [] }
// Default mock for collectAllNodes - returns empty array
mockCollectAllNodes.mockReturnValue([])
})
describe('core filtering logic', () => {
it('filters out installed packs correctly', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { missingNodePacks } = useMissingNodes()
// Should only include packs that are not installed (pack-1, pack-2)
expect(missingNodePacks.value).toHaveLength(2)
expect(missingNodePacks.value[0].id).toBe('pack-1')
expect(missingNodePacks.value[1].id).toBe('pack-2')
expect(
missingNodePacks.value.find((pack) => pack.id === 'pack-3')
).toBeUndefined()
})
it('returns empty array when all packs are installed', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
// Mock all packs as installed
mockIsPackInstalled.mockReturnValue(true)
const { missingNodePacks } = useMissingNodes()
expect(missingNodePacks.value).toEqual([])
})
it('returns all packs when none are installed', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
// Mock no packs as installed
mockIsPackInstalled.mockReturnValue(false)
const { missingNodePacks } = useMissingNodes()
expect(missingNodePacks.value).toHaveLength(3)
expect(missingNodePacks.value).toEqual(mockWorkflowPacks)
})
it('returns empty array when no workflow packs exist', () => {
const { missingNodePacks } = useMissingNodes()
expect(missingNodePacks.value).toEqual([])
})
})
describe('automatic data fetching', () => {
it('fetches workflow packs automatically on initialization via watch with immediate:true', async () => {
useMissingNodes()
expect(mockStartFetchWorkflowPacks).toHaveBeenCalledOnce()
})
it('fetches even when packs already exist (watch always fires with immediate:true)', async () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref(mockWorkflowPacks),
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
useMissingNodes()
expect(mockStartFetchWorkflowPacks).toHaveBeenCalledOnce()
})
it('fetches even when already loading (watch fires regardless of loading state)', async () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
})
useMissingNodes()
expect(mockStartFetchWorkflowPacks).toHaveBeenCalledOnce()
})
})
describe('state management', () => {
it('exposes loading state from useWorkflowPacks', () => {
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
})
const { isLoading } = useMissingNodes()
expect(isLoading.value).toBe(true)
})
it('exposes error state from useWorkflowPacks', () => {
const testError = 'Failed to fetch workflow packs'
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: ref([]),
isLoading: ref(false),
error: ref(testError),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(false),
filterWorkflowPack: vi.fn()
})
const { error } = useMissingNodes()
expect(error.value).toBe(testError)
})
})
describe('reactivity', () => {
it('updates when workflow packs change', async () => {
const workflowPacksRef = ref([])
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: workflowPacksRef,
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { missingNodePacks } = useMissingNodes()
// Initially empty
expect(missingNodePacks.value).toEqual([])
// Update workflow packs
// @ts-expect-error - mockWorkflowPacks is a simplified version without full WorkflowPack interface.
workflowPacksRef.value = mockWorkflowPacks
await nextTick()
// Should update missing packs (2 missing since pack-3 is installed)
expect(missingNodePacks.value).toHaveLength(2)
})
it('clears missing nodes when switching to empty workflow', async () => {
const workflowPacksRef = ref(mockWorkflowPacks)
mockUseWorkflowPacks.mockReturnValue({
workflowPacks: workflowPacksRef,
isLoading: ref(false),
error: ref(null),
startFetchWorkflowPacks: mockStartFetchWorkflowPacks,
isReady: ref(true),
filterWorkflowPack: vi.fn()
})
const { hasMissingNodes, missingNodePacks } = useMissingNodes()
// Should have missing nodes initially (2 missing since pack-3 is installed)
expect(missingNodePacks.value).toHaveLength(2)
expect(hasMissingNodes.value).toBe(true)
// Switch to empty workflow (simulates creating a new empty workflow)
workflowPacksRef.value = []
await nextTick()
// Should clear missing nodes
expect(missingNodePacks.value).toHaveLength(0)
expect(hasMissingNodes.value).toBe(false)
})
})
describe('missing core nodes detection', () => {
const createMockNode = (type: string, packId?: string, version?: string) =>
({
type,
properties: { cnr_id: packId, ver: version },
id: 1,
title: type,
pos: [0, 0],
size: [100, 100],
flags: {},
graph: null,
mode: 0,
inputs: [],
outputs: []
}) as unknown as LGraphNode
it('identifies missing core nodes not in nodeDefStore', () => {
const coreNode1 = createMockNode('CoreNode1', 'comfy-core', '1.2.0')
const coreNode2 = createMockNode('CoreNode2', 'comfy-core', '1.2.0')
// Mock collectAllNodes to return only the filtered nodes (missing core nodes)
mockCollectAllNodes.mockReturnValue([coreNode1, coreNode2])
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
// Only including required properties for our test assertions.
RegisteredNode: { name: 'RegisteredNode' }
}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
expect(missingCoreNodes.value['1.2.0']).toHaveLength(2)
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode1')
expect(missingCoreNodes.value['1.2.0'][1].type).toBe('CoreNode2')
})
it('groups missing core nodes by version', () => {
const node120 = createMockNode('Node120', 'comfy-core', '1.2.0')
const node130 = createMockNode('Node130', 'comfy-core', '1.3.0')
const nodeNoVer = createMockNode('NodeNoVer', 'comfy-core')
// Mock collectAllNodes to return these nodes
mockCollectAllNodes.mockReturnValue([node120, node130, nodeNoVer])
// @ts-expect-error - Mocking partial NodeDefStore for testing.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(3)
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.3.0']).toHaveLength(1)
expect(missingCoreNodes.value['']).toHaveLength(1)
})
it('ignores non-core nodes', () => {
const coreNode = createMockNode('CoreNode', 'comfy-core', '1.2.0')
// Mock collectAllNodes to return only the filtered nodes (core nodes only)
mockCollectAllNodes.mockReturnValue([coreNode])
// @ts-expect-error - Mocking partial NodeDefStore for testing.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(1)
expect(missingCoreNodes.value['1.2.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.2.0'][0].type).toBe('CoreNode')
})
it('returns empty object when no core nodes are missing', () => {
// Mock collectAllNodes to return empty array (no missing nodes after filtering)
mockCollectAllNodes.mockReturnValue([])
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
// Only including required properties for our test assertions.
RegisteredNode1: { name: 'RegisteredNode1' },
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
RegisteredNode2: { name: 'RegisteredNode2' }
}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(0)
})
})
describe('subgraph support', () => {
const createMockNode = (
type: string,
packId?: string,
version?: string
): LGraphNode =>
// @ts-expect-error - Creating a partial mock of LGraphNode for testing.
// We only need specific properties for our tests, not the full LGraphNode interface.
({
type,
properties: { cnr_id: packId, ver: version },
id: 1,
title: type,
pos: [0, 0],
size: [100, 100],
flags: {},
graph: null,
mode: 0,
inputs: [],
outputs: []
})
it('detects missing core nodes from subgraphs via collectAllNodes', () => {
const mainNode = createMockNode('MainNode', 'comfy-core', '1.0.0')
const subgraphNode1 = createMockNode(
'SubgraphNode1',
'comfy-core',
'1.0.0'
)
const subgraphNode2 = createMockNode(
'SubgraphNode2',
'comfy-core',
'1.1.0'
)
// Mock collectAllNodes to return all nodes including subgraph nodes
mockCollectAllNodes.mockReturnValue([
mainNode,
subgraphNode1,
subgraphNode2
])
// Mock none of the nodes as registered
// @ts-expect-error - Mocking partial NodeDefStore for testing.
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {}
})
const { missingCoreNodes } = useMissingNodes()
// Should detect all 3 nodes as missing
expect(Object.keys(missingCoreNodes.value)).toHaveLength(2) // 2 versions: 1.0.0, 1.1.0
expect(missingCoreNodes.value['1.0.0']).toHaveLength(2) // MainNode + SubgraphNode1
expect(missingCoreNodes.value['1.1.0']).toHaveLength(1) // SubgraphNode2
})
it('calls collectAllNodes with the app graph and filter function', () => {
const mockGraph = { nodes: [], subgraphs: new Map() }
mockApp.rootGraph = mockGraph
const { missingCoreNodes } = useMissingNodes()
// Access the computed to trigger the function
void missingCoreNodes.value
expect(mockCollectAllNodes).toHaveBeenCalledWith(
mockGraph,
expect.any(Function)
)
})
it('handles collectAllNodes returning empty array', () => {
mockCollectAllNodes.mockReturnValue([])
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(0)
})
it('filter function correctly identifies missing core nodes', () => {
const mockGraph = { nodes: [], subgraphs: new Map() }
mockApp.rootGraph = mockGraph
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
RegisteredCore: { name: 'RegisteredCore' }
}
})
let capturedFilterFunction: ((node: LGraphNode) => boolean) | undefined
mockCollectAllNodes.mockImplementation((_graph, filter) => {
capturedFilterFunction = filter
return []
})
const { missingCoreNodes } = useMissingNodes()
void missingCoreNodes.value
expect(capturedFilterFunction).toBeDefined()
if (capturedFilterFunction) {
const missingCoreNode = createMockNode(
'MissingCore',
'comfy-core',
'1.0.0'
)
const registeredCoreNode = createMockNode(
'RegisteredCore',
'comfy-core',
'1.0.0'
)
const customNode = createMockNode('CustomNode', 'custom-pack', '1.0.0')
const nodeWithoutPack = createMockNode('NodeWithoutPack')
expect(capturedFilterFunction(missingCoreNode)).toBe(true)
expect(capturedFilterFunction(registeredCoreNode)).toBe(false)
expect(capturedFilterFunction(customNode)).toBe(false)
expect(capturedFilterFunction(nodeWithoutPack)).toBe(false)
}
})
it('integrates with collectAllNodes to find nodes from subgraphs', () => {
mockCollectAllNodes.mockImplementation((graph, filter) => {
const allNodes: LGraphNode[] = []
for (const node of graph.nodes) {
if (node.isSubgraphNode?.() && node.subgraph) {
for (const subNode of node.subgraph.nodes) {
if (!filter || filter(subNode)) {
allNodes.push(subNode)
}
}
}
if (!filter || filter(node)) {
allNodes.push(node)
}
}
return allNodes
})
const mainMissingNode = createMockNode(
'MainMissing',
'comfy-core',
'1.0.0'
)
const subgraphMissingNode = createMockNode(
'SubgraphMissing',
'comfy-core',
'1.1.0'
)
const subgraphRegisteredNode = createMockNode(
'SubgraphRegistered',
'comfy-core',
'1.0.0'
)
const mockSubgraph = {
nodes: [subgraphMissingNode, subgraphRegisteredNode]
}
const mockSubgraphNode = {
isSubgraphNode: () => true,
subgraph: mockSubgraph,
type: 'SubgraphContainer',
properties: { cnr_id: 'custom-pack' }
} as unknown as LGraphNode
const mockMainGraph = {
nodes: [mainMissingNode, mockSubgraphNode]
} as Partial<LGraph> as LGraph
mockApp.rootGraph = mockMainGraph
mockUseNodeDefStore.mockReturnValue({
nodeDefsByName: {
// @ts-expect-error - Creating minimal mock of ComfyNodeDefImpl for testing.
SubgraphRegistered: { name: 'SubgraphRegistered' }
}
})
const { missingCoreNodes } = useMissingNodes()
expect(Object.keys(missingCoreNodes.value)).toHaveLength(2)
expect(missingCoreNodes.value['1.0.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.1.0']).toHaveLength(1)
expect(missingCoreNodes.value['1.0.0'][0].type).toBe('MainMissing')
expect(missingCoreNodes.value['1.1.0'][0].type).toBe('SubgraphMissing')
})
})
})

View File

@@ -0,0 +1,378 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { usePacksSelection } from '@/workbench/extensions/manager/composables/nodePack/usePacksSelection'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual('vue-i18n')
return {
...actual,
useI18n: () => ({
t: vi.fn((key) => key)
})
}
})
type NodePack = components['schemas']['Node']
describe('usePacksSelection', () => {
let managerStore: ReturnType<typeof useComfyManagerStore>
let mockIsPackInstalled: (packName: string | undefined) => boolean
const createMockPack = (id: string): NodePack => ({
id,
name: `Pack ${id}`,
description: `Description for pack ${id}`,
category: 'Nodes',
author: 'Test Author',
license: 'MIT',
repository: 'https://github.com/test/pack',
tags: [],
status: 'NodeStatusActive'
})
beforeEach(() => {
vi.clearAllMocks()
const pinia = createPinia()
setActivePinia(pinia)
managerStore = useComfyManagerStore()
// Mock the isPackInstalled method
mockIsPackInstalled = vi.fn()
managerStore.isPackInstalled = mockIsPackInstalled
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('installedPacks', () => {
it('should filter and return only installed packs', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => {
return id === 'pack1' || id === 'pack3'
})
const { installedPacks } = usePacksSelection(nodePacks)
expect(installedPacks.value).toHaveLength(2)
expect(installedPacks.value[0].id).toBe('pack1')
expect(installedPacks.value[1].id).toBe('pack3')
expect(mockIsPackInstalled).toHaveBeenCalledTimes(3)
})
it('should return empty array when no packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(false)
const { installedPacks } = usePacksSelection(nodePacks)
expect(installedPacks.value).toHaveLength(0)
})
it('should update when nodePacks ref changes', () => {
const nodePacks = ref<NodePack[]>([createMockPack('pack1')])
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
const { installedPacks } = usePacksSelection(nodePacks)
expect(installedPacks.value).toHaveLength(1)
// Add more packs
nodePacks.value = [
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
]
expect(installedPacks.value).toHaveLength(3)
})
})
describe('notInstalledPacks', () => {
it('should filter and return only not installed packs', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => {
return id === 'pack1'
})
const { notInstalledPacks } = usePacksSelection(nodePacks)
expect(notInstalledPacks.value).toHaveLength(2)
expect(notInstalledPacks.value[0].id).toBe('pack2')
expect(notInstalledPacks.value[1].id).toBe('pack3')
})
it('should return all packs when none are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(false)
const { notInstalledPacks } = usePacksSelection(nodePacks)
expect(notInstalledPacks.value).toHaveLength(2)
})
})
describe('isAllInstalled', () => {
it('should return true when all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
const { isAllInstalled } = usePacksSelection(nodePacks)
expect(isAllInstalled.value).toBe(true)
})
it('should return false when not all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => id === 'pack1')
const { isAllInstalled } = usePacksSelection(nodePacks)
expect(isAllInstalled.value).toBe(false)
})
it('should return true for empty array', () => {
const nodePacks = ref<NodePack[]>([])
const { isAllInstalled } = usePacksSelection(nodePacks)
expect(isAllInstalled.value).toBe(true)
})
})
describe('isNoneInstalled', () => {
it('should return true when no packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(false)
const { isNoneInstalled } = usePacksSelection(nodePacks)
expect(isNoneInstalled.value).toBe(true)
})
it('should return false when some packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => id === 'pack1')
const { isNoneInstalled } = usePacksSelection(nodePacks)
expect(isNoneInstalled.value).toBe(false)
})
it('should return true for empty array', () => {
const nodePacks = ref<NodePack[]>([])
const { isNoneInstalled } = usePacksSelection(nodePacks)
expect(isNoneInstalled.value).toBe(true)
})
})
describe('isMixed', () => {
it('should return true when some but not all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => {
return id === 'pack1' || id === 'pack2'
})
const { isMixed } = usePacksSelection(nodePacks)
expect(isMixed.value).toBe(true)
})
it('should return false when all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
const { isMixed } = usePacksSelection(nodePacks)
expect(isMixed.value).toBe(false)
})
it('should return false when no packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(false)
const { isMixed } = usePacksSelection(nodePacks)
expect(isMixed.value).toBe(false)
})
it('should return false for empty array', () => {
const nodePacks = ref<NodePack[]>([])
const { isMixed } = usePacksSelection(nodePacks)
expect(isMixed.value).toBe(false)
})
})
describe('selectionState', () => {
it('should return "all-installed" when all packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('all-installed')
})
it('should return "none-installed" when no packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(false)
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('none-installed')
})
it('should return "mixed" when some packs are installed', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => id === 'pack1')
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('mixed')
})
it('should update when installation status changes', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockReturnValue(false)
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('none-installed')
// Change mock to simulate installation
vi.mocked(mockIsPackInstalled).mockReturnValue(true)
// Force reactivity update
nodePacks.value = [...nodePacks.value]
expect(selectionState.value).toBe('all-installed')
})
})
describe('edge cases', () => {
it('should handle packs with undefined ids', () => {
const nodePacks = ref<NodePack[]>([
{ ...createMockPack('pack1'), id: undefined as any },
createMockPack('pack2')
])
vi.mocked(mockIsPackInstalled).mockImplementation((id) => id === 'pack2')
const { installedPacks, notInstalledPacks } = usePacksSelection(nodePacks)
expect(installedPacks.value).toHaveLength(1)
expect(installedPacks.value[0].id).toBe('pack2')
expect(notInstalledPacks.value).toHaveLength(1)
})
it('should handle dynamic changes to pack installation status', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
const installationStatus: Record<string, boolean> = {
pack1: false,
pack2: false
}
vi.mocked(mockIsPackInstalled).mockImplementation(
(id) => (id && installationStatus[id]) || false
)
const { installedPacks, notInstalledPacks, selectionState } =
usePacksSelection(nodePacks)
expect(selectionState.value).toBe('none-installed')
expect(installedPacks.value).toHaveLength(0)
expect(notInstalledPacks.value).toHaveLength(2)
// Simulate installing pack1
installationStatus.pack1 = true
nodePacks.value = [...nodePacks.value] // Trigger reactivity
expect(selectionState.value).toBe('mixed')
expect(installedPacks.value).toHaveLength(1)
expect(notInstalledPacks.value).toHaveLength(1)
// Simulate installing pack2
installationStatus.pack2 = true
nodePacks.value = [...nodePacks.value] // Trigger reactivity
expect(selectionState.value).toBe('all-installed')
expect(installedPacks.value).toHaveLength(2)
expect(notInstalledPacks.value).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,384 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { usePacksStatus } from '@/workbench/extensions/manager/composables/nodePack/usePacksStatus'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
type NodePack = components['schemas']['Node']
type NodeStatus = components['schemas']['NodeStatus']
type NodeVersionStatus = components['schemas']['NodeVersionStatus']
describe('usePacksStatus', () => {
let conflictDetectionStore: ReturnType<typeof useConflictDetectionStore>
const createMockPack = (
id: string,
status?: NodeStatus | NodeVersionStatus
): NodePack => ({
id,
name: `Pack ${id}`,
description: `Description for pack ${id}`,
category: 'Nodes',
author: 'Test Author',
license: 'MIT',
repository: 'https://github.com/test/pack',
tags: [],
status: (status || 'NodeStatusActive') as NodeStatus
})
const createMockConflict = (
packageId: string,
type: 'import_failed' | 'banned' | 'pending' = 'import_failed'
): ConflictDetectionResult => ({
package_id: packageId,
package_name: `Pack ${packageId}`,
has_conflict: true,
conflicts: [
{
type,
current_value: 'current',
required_value: 'required'
}
],
is_compatible: false
})
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
conflictDetectionStore = useConflictDetectionStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('hasImportFailed', () => {
it('should return true when at least one pack has import_failed conflict', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2'),
createMockPack('pack3')
])
// Set up mock conflicts
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack2', 'import_failed'),
createMockConflict('pack3', 'banned')
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
})
it('should return false when no pack has import_failed conflict', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
// Set up mock conflicts with no import_failed
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack1', 'pending'),
createMockConflict('pack2', 'banned')
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(false)
})
it('should return false when no conflicts exist', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
conflictDetectionStore.setConflictedPackages([])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(false)
})
it('should handle packs without ids', () => {
const nodePacks = ref<NodePack[]>([
{ ...createMockPack('pack1'), id: undefined as any },
createMockPack('pack2')
])
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack2', 'import_failed')
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
})
it('should update when conflicts change', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
conflictDetectionStore.setConflictedPackages([])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(false)
// Add import_failed conflict
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack1', 'import_failed')
])
expect(hasImportFailed.value).toBe(true)
})
})
describe('overallStatus', () => {
it('should prioritize banned status over all others', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusBanned'),
createMockPack('pack3', 'NodeVersionStatusDeleted')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeStatusBanned')
})
it('should prioritize version banned over deleted and active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeVersionStatusBanned'),
createMockPack('pack3', 'NodeVersionStatusDeleted')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusBanned')
})
it('should prioritize deleted status appropriately', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusDeleted'),
createMockPack('pack3', 'NodeVersionStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeStatusDeleted')
})
it('should prioritize version deleted over flagged and active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeVersionStatusFlagged'),
createMockPack('pack2', 'NodeVersionStatusDeleted'),
createMockPack('pack3', 'NodeVersionStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusDeleted')
})
it('should prioritize flagged status over pending and active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeVersionStatusPending'),
createMockPack('pack2', 'NodeVersionStatusFlagged'),
createMockPack('pack3', 'NodeVersionStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusFlagged')
})
it('should prioritize pending status over active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeVersionStatusActive'),
createMockPack('pack2', 'NodeVersionStatusPending'),
createMockPack('pack3', 'NodeStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusPending')
})
it('should return NodeStatusActive when all packs are active', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeStatusActive')
})
it('should return NodeStatusActive as default when all packs have no status', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
const { overallStatus } = usePacksStatus(nodePacks)
// Since createMockPack sets status to 'NodeStatusActive' by default
expect(overallStatus.value).toBe('NodeStatusActive')
})
it('should handle empty pack array', () => {
const nodePacks = ref<NodePack[]>([])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusActive')
})
it('should update when pack statuses change', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeStatusActive')
// Change one pack to banned
nodePacks.value = [
createMockPack('pack1', 'NodeStatusBanned'),
createMockPack('pack2', 'NodeStatusActive')
]
expect(overallStatus.value).toBe('NodeStatusBanned')
})
})
describe('integration with import failures', () => {
it('should return NodeVersionStatusActive when import failures exist (handled separately)', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusActive'),
createMockPack('pack2', 'NodeStatusActive')
])
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack1', 'import_failed')
])
const { hasImportFailed, overallStatus } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
// When import failed exists, it returns NodeVersionStatusActive
expect(overallStatus.value).toBe('NodeVersionStatusActive')
})
it('should return NodeVersionStatusActive when import failures exist even with banned status', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusBanned'),
createMockPack('pack2', 'NodeStatusActive')
])
conflictDetectionStore.setConflictedPackages([
createMockConflict('pack2', 'import_failed')
])
const { hasImportFailed, overallStatus } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
// Import failed takes priority and returns NodeVersionStatusActive
expect(overallStatus.value).toBe('NodeVersionStatusActive')
})
})
describe('edge cases', () => {
it('should handle multiple conflicts per package', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
conflictDetectionStore.setConflictedPackages([
{
package_id: 'pack1',
package_name: 'Pack pack1',
has_conflict: true,
conflicts: [
{
type: 'pending',
current_value: 'current1',
required_value: 'required1'
},
{
type: 'import_failed',
current_value: 'current2',
required_value: 'required2'
}
],
is_compatible: false
}
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(true)
})
it('should handle packs with no conflicts in store', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1'),
createMockPack('pack2')
])
const { hasImportFailed } = usePacksStatus(nodePacks)
expect(hasImportFailed.value).toBe(false)
})
it('should handle mixed status types correctly', () => {
const nodePacks = ref<NodePack[]>([
createMockPack('pack1', 'NodeStatusBanned'),
createMockPack('pack2', 'NodeVersionStatusBanned'),
createMockPack('pack3', 'NodeStatusDeleted'),
createMockPack('pack4', 'NodeVersionStatusDeleted'),
createMockPack('pack5', 'NodeVersionStatusFlagged'),
createMockPack('pack6', 'NodeVersionStatusPending'),
createMockPack('pack7', 'NodeStatusActive'),
createMockPack('pack8', 'NodeVersionStatusActive')
])
const { overallStatus } = usePacksStatus(nodePacks)
// Should return the highest priority status (NodeStatusBanned)
expect(overallStatus.value).toBe('NodeStatusBanned')
})
it('should be reactive to nodePacks changes', () => {
const nodePacks = ref<NodePack[]>([])
const { overallStatus } = usePacksStatus(nodePacks)
expect(overallStatus.value).toBe('NodeVersionStatusActive')
// Add packs
nodePacks.value = [
createMockPack('pack1', 'NodeStatusDeleted'),
createMockPack('pack2', 'NodeStatusActive')
]
expect(overallStatus.value).toBe('NodeStatusDeleted')
// Add a higher priority status
nodePacks.value.push(createMockPack('pack3', 'NodeStatusBanned'))
expect(overallStatus.value).toBe('NodeStatusBanned')
})
})
})

View File

@@ -0,0 +1,558 @@
import { compare, valid } from 'semver'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { components } from '@/types/comfyRegistryTypes'
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
import { useUpdateAvailableNodes } from '@/workbench/extensions/manager/composables/nodePack/useUpdateAvailableNodes'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
// Mock Vue's onMounted to execute immediately for testing
vi.mock('vue', async () => {
const actual = await vi.importActual('vue')
return {
...actual,
onMounted: (cb: () => void) => cb()
}
})
// Mock the dependencies
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useInstalledPacks',
() => ({
useInstalledPacks: vi.fn()
})
)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
vi.mock('semver', () => ({
compare: vi.fn(),
valid: vi.fn()
}))
const mockUseInstalledPacks = vi.mocked(useInstalledPacks)
const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore)
const mockSemverCompare = vi.mocked(compare)
const mockSemverValid = vi.mocked(valid)
describe('useUpdateAvailableNodes', () => {
const mockInstalledPacks = [
{
id: 'pack-1',
name: 'Outdated Pack',
latest_version: { version: '2.0.0' }
} as components['schemas']['Node'],
{
id: 'pack-2',
name: 'Up to Date Pack',
latest_version: { version: '1.0.0' }
} as components['schemas']['Node'],
{
id: 'pack-3',
name: 'Nightly Pack',
latest_version: { version: '1.5.0' }
} as components['schemas']['Node'],
{
id: 'pack-4',
name: 'No Latest Version',
latest_version: undefined
} as components['schemas']['Node']
]
const mockStartFetchInstalled = vi.fn()
const mockIsPackInstalled = vi.fn()
const mockGetInstalledPackVersion = vi.fn()
const mockIsPackEnabled = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Default setup
mockIsPackInstalled.mockReturnValue(true)
mockIsPackEnabled.mockReturnValue(true) // Default: all packs are enabled
mockGetInstalledPackVersion.mockImplementation((id: string) => {
switch (id) {
case 'pack-1':
return '1.0.0' // outdated
case 'pack-2':
return '1.0.0' // up to date
case 'pack-3':
return 'nightly-abc123' // nightly
case 'pack-4':
return '1.0.0' // no latest version
default:
return '1.0.0'
}
})
mockSemverValid.mockImplementation((version) => {
return version &&
typeof version === 'string' &&
!version.includes('nightly')
? version
: null
})
mockSemverCompare.mockImplementation((latest, installed) => {
if (latest === '2.0.0' && installed === '1.0.0') return 1 // outdated
if (latest === '1.0.0' && installed === '1.0.0') return 0 // up to date
return 0
})
mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled,
getInstalledPackVersion: mockGetInstalledPackVersion,
isPackEnabled: mockIsPackEnabled
} as unknown as ReturnType<typeof useComfyManagerStore>)
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
})
describe('core filtering logic', () => {
it('identifies outdated packs correctly', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Should only include pack-1 (outdated)
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(updateAvailableNodePacks.value[0].id).toBe('pack-1')
})
it('excludes up-to-date packs', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('excludes nightly packs from updates', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('excludes packs with no latest version', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[3]]), // pack-4: no latest version
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('excludes uninstalled packs', () => {
mockIsPackInstalled.mockReturnValue(false)
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(0)
})
it('returns empty array when no installed packs exist', () => {
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toEqual([])
})
})
describe('hasUpdateAvailable computed', () => {
it('returns true when updates are available', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(true)
})
it('returns false when no updates are available', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(false)
})
})
describe('automatic data fetching', () => {
it('fetches installed packs automatically when none exist', () => {
useUpdateAvailableNodes()
expect(mockStartFetchInstalled).toHaveBeenCalledOnce()
})
it('does not fetch when packs already exist', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
useUpdateAvailableNodes()
expect(mockStartFetchInstalled).not.toHaveBeenCalled()
})
it('does not fetch when already loading', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
useUpdateAvailableNodes()
expect(mockStartFetchInstalled).not.toHaveBeenCalled()
})
})
describe('state management', () => {
it('exposes loading state from useInstalledPacks', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(true),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { isLoading } = useUpdateAvailableNodes()
expect(isLoading.value).toBe(true)
})
it('exposes error state from useInstalledPacks', () => {
const testError = 'Failed to fetch installed packs'
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([]),
isLoading: ref(false),
error: ref(testError),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { error } = useUpdateAvailableNodes()
expect(error.value).toBe(testError)
})
})
describe('reactivity', () => {
it('updates when installed packs change', async () => {
const installedPacksRef = ref<components['schemas']['Node'][]>([])
mockUseInstalledPacks.mockReturnValue({
installedPacks: installedPacksRef,
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks, hasUpdateAvailable } =
useUpdateAvailableNodes()
// Initially empty
expect(updateAvailableNodePacks.value).toEqual([])
expect(hasUpdateAvailable.value).toBe(false)
// Update installed packs
installedPacksRef.value = [mockInstalledPacks[0]]
await nextTick()
// Should update available updates
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(hasUpdateAvailable.value).toBe(true)
})
})
describe('version comparison logic', () => {
it('calls compareVersions with correct parameters', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockSemverCompare).toHaveBeenCalledWith('2.0.0', '1.0.0')
})
it('calls semver.valid to check nightly versions', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockSemverValid).toHaveBeenCalledWith('nightly-abc123')
})
it('calls isPackInstalled for each pack', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref(mockInstalledPacks),
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks } = useUpdateAvailableNodes()
// Access the computed to trigger the logic
expect(updateAvailableNodePacks.value).toBeDefined()
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-1')
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-2')
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-3')
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-4')
})
})
describe('enabledUpdateAvailableNodePacks', () => {
it('returns only enabled packs with updates', () => {
mockIsPackEnabled.mockImplementation((id: string) => {
// pack-1 is disabled
return id !== 'pack-1'
})
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0], mockInstalledPacks[1]]),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks, enabledUpdateAvailableNodePacks } =
useUpdateAvailableNodes()
// pack-1 has updates but is disabled
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(updateAvailableNodePacks.value[0].id).toBe('pack-1')
// enabledUpdateAvailableNodePacks should be empty
expect(enabledUpdateAvailableNodePacks.value).toHaveLength(0)
})
it('returns all packs when all are enabled', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { updateAvailableNodePacks, enabledUpdateAvailableNodePacks } =
useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(enabledUpdateAvailableNodePacks.value).toHaveLength(1)
expect(enabledUpdateAvailableNodePacks.value[0].id).toBe('pack-1')
})
})
describe('hasDisabledUpdatePacks', () => {
it('returns true when there are disabled packs with updates', () => {
mockIsPackEnabled.mockImplementation((id: string) => {
// pack-1 is disabled
return id !== 'pack-1'
})
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
expect(hasDisabledUpdatePacks.value).toBe(true)
})
it('returns false when all packs with updates are enabled', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
expect(hasDisabledUpdatePacks.value).toBe(false)
})
it('returns false when no packs have updates', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
isLoading: ref(false),
isReady: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
expect(hasDisabledUpdatePacks.value).toBe(false)
})
})
describe('hasUpdateAvailable with disabled packs', () => {
it('returns false when only disabled packs have updates', () => {
mockIsPackEnabled.mockReturnValue(false) // All packs disabled
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(false)
})
it('returns true when at least one enabled pack has updates', () => {
mockIsPackEnabled.mockImplementation((id: string) => {
// Only pack-1 is enabled
return id === 'pack-1'
})
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled,
isReady: ref(false),
installedPacksWithVersions: ref([]),
filterInstalledPack: vi.fn()
} as unknown as ReturnType<typeof useInstalledPacks>)
const { hasUpdateAvailable } = useUpdateAvailableNodes()
expect(hasUpdateAvailable.value).toBe(true)
})
})
})

View File

@@ -0,0 +1,176 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('useConflictAcknowledgment', () => {
beforeEach(() => {
// Set up Pinia for each test
setActivePinia(createPinia())
// Clear localStorage before each test
localStorage.clear()
// Reset modules to ensure fresh state
vi.resetModules()
})
afterEach(() => {
localStorage.clear()
})
describe('initial state loading', () => {
it('should load empty state when localStorage is empty', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { acknowledgmentState } = useConflictAcknowledgment()
expect(acknowledgmentState.value).toEqual({
modal_dismissed: false,
red_dot_dismissed: false,
warning_banner_dismissed: false
})
})
it('should load existing state from localStorage', async () => {
// Pre-populate localStorage with JSON values (as useStorage expects)
localStorage.setItem('Comfy.ConflictModalDismissed', JSON.stringify(true))
localStorage.setItem(
'Comfy.ConflictRedDotDismissed',
JSON.stringify(true)
)
localStorage.setItem(
'Comfy.ConflictWarningBannerDismissed',
JSON.stringify(true)
)
// Need to import the module after localStorage is set
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { acknowledgmentState } = useConflictAcknowledgment()
expect(acknowledgmentState.value).toEqual({
modal_dismissed: true,
red_dot_dismissed: true,
warning_banner_dismissed: true
})
})
})
describe('dismissal functions', () => {
it('should mark conflicts as seen with unified function', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { markConflictsAsSeen, acknowledgmentState } =
useConflictAcknowledgment()
markConflictsAsSeen()
expect(acknowledgmentState.value.modal_dismissed).toBe(true)
})
it('should dismiss red dot notification', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { dismissRedDotNotification, acknowledgmentState } =
useConflictAcknowledgment()
dismissRedDotNotification()
expect(acknowledgmentState.value.red_dot_dismissed).toBe(true)
})
it('should dismiss warning banner', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { dismissWarningBanner, acknowledgmentState } =
useConflictAcknowledgment()
dismissWarningBanner()
expect(acknowledgmentState.value.warning_banner_dismissed).toBe(true)
})
it('should mark all conflicts as seen', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { markConflictsAsSeen, acknowledgmentState } =
useConflictAcknowledgment()
markConflictsAsSeen()
expect(acknowledgmentState.value.modal_dismissed).toBe(true)
expect(acknowledgmentState.value.red_dot_dismissed).toBe(true)
expect(acknowledgmentState.value.warning_banner_dismissed).toBe(true)
})
})
describe('computed properties', () => {
it('should calculate shouldShowConflictModal correctly', async () => {
// Need fresh module import to ensure clean state
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { shouldShowConflictModal, markConflictsAsSeen } =
useConflictAcknowledgment()
expect(shouldShowConflictModal.value).toBe(true)
markConflictsAsSeen()
expect(shouldShowConflictModal.value).toBe(false)
})
it('should calculate shouldShowRedDot correctly based on conflicts', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { shouldShowRedDot, dismissRedDotNotification } =
useConflictAcknowledgment()
// Initially false because no conflicts exist
expect(shouldShowRedDot.value).toBe(false)
dismissRedDotNotification()
expect(shouldShowRedDot.value).toBe(false)
})
it('should calculate shouldShowManagerBanner correctly', async () => {
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { shouldShowManagerBanner, dismissWarningBanner } =
useConflictAcknowledgment()
// Initially false because no conflicts exist
expect(shouldShowManagerBanner.value).toBe(false)
dismissWarningBanner()
expect(shouldShowManagerBanner.value).toBe(false)
})
})
describe('localStorage persistence', () => {
it('should persist to localStorage automatically', async () => {
// Need fresh module import to ensure clean state
vi.resetModules()
const { useConflictAcknowledgment } =
await import('@/workbench/extensions/manager/composables/useConflictAcknowledgment')
const { markConflictsAsSeen, dismissWarningBanner } =
useConflictAcknowledgment()
markConflictsAsSeen()
dismissWarningBanner()
// Wait a tick for useStorage to sync
await new Promise((resolve) => setTimeout(resolve, 10))
// VueUse useStorage should automatically persist to localStorage as JSON
expect(localStorage.getItem('Comfy.ConflictModalDismissed')).toBe('true')
expect(localStorage.getItem('Comfy.ConflictWarningBannerDismissed')).toBe(
'true'
)
})
})
})

View File

@@ -0,0 +1,481 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import { useComfyRegistryService } from '@/services/comfyRegistryService'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { components } from '@/types/comfyRegistryTypes'
import { useInstalledPacks } from '@/workbench/extensions/manager/composables/nodePack/useInstalledPacks'
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import { checkVersionCompatibility } from '@/workbench/extensions/manager/utils/versionUtil'
// Mock @vueuse/core until function
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
until: vi.fn(() => ({
toBe: vi.fn(() => Promise.resolve())
}))
}
})
// Mock dependencies
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
useComfyManagerService: vi.fn()
}))
vi.mock('@/services/comfyRegistryService', () => ({
useComfyRegistryService: vi.fn()
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn()
}))
vi.mock('@/workbench/extensions/manager/utils/versionUtil', () => ({
getFrontendVersion: vi.fn(() => '1.24.0'),
checkVersionCompatibility: vi.fn()
}))
vi.mock('@/workbench/extensions/manager/utils/systemCompatibility', () => ({
checkOSCompatibility: vi.fn(),
checkAcceleratorCompatibility: vi.fn(),
normalizeOSList: vi.fn((list) => list)
}))
vi.mock('@/workbench/extensions/manager/utils/conflictUtils', () => ({
consolidateConflictsByPackage: vi.fn((results) => results),
createBannedConflict: vi.fn((isBanned) =>
isBanned
? {
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
: null
),
createPendingConflict: vi.fn((isPending) =>
isPending
? {
type: 'pending',
current_value: 'installed',
required_value: 'not_pending'
}
: null
),
generateConflictSummary: vi.fn((results, duration) => ({
total_packages: results.length,
compatible_packages: results.filter(
(r: ConflictDetectionResult) => r.is_compatible
).length,
conflicted_packages: results.filter(
(r: ConflictDetectionResult) => r.has_conflict
).length,
banned_packages: 0,
pending_packages: 0,
conflicts_by_type_details: {},
last_check_timestamp: new Date().toISOString(),
check_duration_ms: duration
}))
}))
vi.mock(
'@/workbench/extensions/manager/composables/useConflictAcknowledgment',
() => ({
useConflictAcknowledgment: vi.fn()
})
)
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useInstalledPacks',
() => ({
useInstalledPacks: vi.fn()
})
)
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))
vi.mock('@/workbench/extensions/manager/stores/conflictDetectionStore', () => ({
useConflictDetectionStore: vi.fn()
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
useManagerState: vi.fn(() => ({
isNewManagerUI: { value: true }
}))
}))
describe('useConflictDetection', () => {
let pinia: ReturnType<typeof createPinia>
const mockComfyManagerService = {
getImportFailInfoBulk: vi.fn(),
isLoading: ref(false),
error: ref<string | null>(null)
} as unknown as ReturnType<typeof useComfyManagerService>
const mockRegistryService = {
getBulkNodeVersions: vi.fn(),
isLoading: ref(false),
error: ref<string | null>(null)
} as unknown as ReturnType<typeof useComfyRegistryService>
// Create a ref that can be modified in tests
const mockInstalledPacksWithVersions = ref<{ id: string; version: string }[]>(
[]
)
const mockInstalledPacks = {
startFetchInstalled: vi.fn(),
installedPacks: ref<components['schemas']['Node'][]>([]),
installedPacksWithVersions: computed(
() => mockInstalledPacksWithVersions.value
),
isReady: ref(false),
isLoading: ref(false),
error: ref<unknown>(null)
} as unknown as ReturnType<typeof useInstalledPacks>
const mockManagerStore = {
isPackEnabled: vi.fn()
} as unknown as ReturnType<typeof useComfyManagerStore>
// Create refs that can be used to control computed properties
const mockConflictedPackages = ref<ConflictDetectionResult[]>([])
const mockConflictStore = {
hasConflicts: computed(() =>
mockConflictedPackages.value.some((p) => p.has_conflict)
),
conflictedPackages: mockConflictedPackages,
bannedPackages: computed(() =>
mockConflictedPackages.value.filter((p) =>
p.conflicts?.some((c) => c.type === 'banned')
)
),
securityPendingPackages: computed(() =>
mockConflictedPackages.value.filter((p) =>
p.conflicts?.some((c) => c.type === 'pending')
)
),
setConflictedPackages: vi.fn(),
clearConflicts: vi.fn()
} as unknown as ReturnType<typeof useConflictDetectionStore>
const mockIsInitialized = ref(true)
const mockSystemStatsStore = {
systemStats: {
system: {
os: 'darwin', // sys.platform returns 'darwin' for macOS
ram_total: 17179869184,
ram_free: 8589934592,
comfyui_version: '0.3.41',
required_frontend_version: '1.24.0',
python_version:
'3.11.0 (main, Oct 13 2023, 09:34:16) [Clang 15.0.0 (clang-1500.0.40.1)]',
pytorch_version: '2.1.0',
embedded_python: false,
argv: ['--enable-manager']
},
devices: [
{
name: 'Apple M1 Pro',
type: 'mps',
index: 0,
vram_total: 17179869184,
vram_free: 8589934592,
torch_vram_total: 17179869184,
torch_vram_free: 8589934592
}
]
},
isInitialized: mockIsInitialized,
$state: {} as never,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
$id: 'systemStats',
_customProperties: new Set<string>()
} as unknown as ReturnType<typeof useSystemStatsStore>
const mockAcknowledgment = {
checkComfyUIVersionChange: vi.fn(),
acknowledgmentState: computed(() => ({})),
shouldShowConflictModal: computed(() => false),
shouldShowRedDot: computed(() => false),
shouldShowManagerBanner: computed(() => false),
dismissRedDotNotification: vi.fn(),
dismissWarningBanner: vi.fn(),
markConflictsAsSeen: vi.fn()
} as unknown as ReturnType<typeof useConflictAcknowledgment>
beforeEach(() => {
vi.clearAllMocks()
pinia = createPinia()
setActivePinia(pinia)
// Setup mocks
vi.mocked(useComfyManagerService).mockReturnValue(mockComfyManagerService)
vi.mocked(useComfyRegistryService).mockReturnValue(mockRegistryService)
vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
vi.mocked(useConflictAcknowledgment).mockReturnValue(mockAcknowledgment)
vi.mocked(useInstalledPacks).mockReturnValue(mockInstalledPacks)
vi.mocked(useComfyManagerStore).mockReturnValue(mockManagerStore)
vi.mocked(useConflictDetectionStore).mockReturnValue(mockConflictStore)
// Reset mock implementations
vi.mocked(mockInstalledPacks.startFetchInstalled).mockResolvedValue(
undefined
)
vi.mocked(mockManagerStore.isPackEnabled).mockReturnValue(true)
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
node_versions: []
})
vi.mocked(mockComfyManagerService.getImportFailInfoBulk).mockResolvedValue(
{}
)
// Reset the installedPacksWithVersions data
mockInstalledPacksWithVersions.value = []
// Reset conflicted packages
mockConflictedPackages.value = []
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('system environment collection', () => {
it('should collect system environment correctly', async () => {
const { collectSystemEnvironment } = useConflictDetection()
const environment = await collectSystemEnvironment()
expect(environment).toEqual({
comfyui_version: '0.3.41',
frontend_version: '1.24.0',
os: 'darwin',
accelerator: 'mps'
})
})
it('should handle missing system stats gracefully', async () => {
mockSystemStatsStore.systemStats = null as never
const { collectSystemEnvironment } = useConflictDetection()
const environment = await collectSystemEnvironment()
// When systemStats is null, empty strings are used as fallback
expect(environment).toEqual({
comfyui_version: '',
frontend_version: '1.24.0',
os: '',
accelerator: ''
})
})
})
describe('conflict detection', () => {
it('should detect version conflicts', async () => {
// Setup installed packages
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{
id: 'test-pack',
name: 'Test Pack',
latest_version: { version: '1.0.0' }
} as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{
id: 'test-pack',
version: '1.0.0'
}
]
// Mock registry response with version requirements
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
node_versions: [
{
status: 'success' as const,
identifier: { node_id: 'test-pack', version: '1.0.0' },
node_version: {
supported_comfyui_version: '>=0.4.0',
supported_comfyui_frontend_version: '>=2.0.0',
supported_os: ['Windows', 'Linux', 'macOS'],
supported_accelerators: ['CUDA', 'Metal', 'CPU'],
status: 'NodeVersionStatusActive' as const,
version: '1.0.0',
publisher_id: 'test-publisher',
node_id: 'test-pack',
created_at: '2024-01-01T00:00:00Z'
} as components['schemas']['NodeVersion']
}
]
})
// Mock version checks to return conflicts
vi.mocked(checkVersionCompatibility).mockImplementation(
(type, current, required) => {
if (type === 'comfyui_version' && required === '>=0.4.0') {
return {
type: 'comfyui_version',
current_value: current || '0.3.41',
required_value: '>=0.4.0'
}
}
return null
}
)
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.success).toBe(true)
expect(result.results).toHaveLength(1)
expect(result.results[0].has_conflict).toBe(true)
expect(result.results[0].conflicts).toContainEqual({
type: 'comfyui_version',
current_value: '0.3.41',
required_value: '>=0.4.0'
})
})
it('should detect banned packages', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacks.installedPacks.value = [
{
id: 'banned-pack',
name: 'Banned Pack'
} as components['schemas']['Node']
]
mockInstalledPacksWithVersions.value = [
{
id: 'banned-pack',
version: '1.0.0'
}
]
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
node_versions: [
{
status: 'success' as const,
identifier: { node_id: 'banned-pack', version: '1.0.0' },
node_version: {
status: 'NodeVersionStatusBanned' as const,
version: '1.0.0',
publisher_id: 'test-publisher',
node_id: 'banned-pack',
created_at: '2024-01-01T00:00:00Z',
supported_comfyui_version: undefined,
supported_comfyui_frontend_version: undefined,
supported_os: undefined,
supported_accelerators: undefined
} as components['schemas']['NodeVersion']
}
]
})
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.results[0].conflicts).toContainEqual({
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
})
})
it('should detect import failures', async () => {
mockInstalledPacks.isReady.value = true
mockInstalledPacksWithVersions.value = [
{
id: 'fail-pack',
version: '1.0.0'
}
]
vi.mocked(
mockComfyManagerService.getImportFailInfoBulk
).mockResolvedValue({
'fail-pack': {
msg: 'Import error',
name: 'fail-pack',
path: '/path/to/pack'
} as any // The actual API returns different structure than types
})
// Mock registry response for the package
vi.mocked(mockRegistryService.getBulkNodeVersions).mockResolvedValue({
node_versions: []
})
const { runFullConflictAnalysis } = useConflictDetection()
const result = await runFullConflictAnalysis()
expect(result.results).toHaveLength(1)
// Import failure should match the actual implementation
expect(result.results[0].conflicts).toContainEqual({
type: 'import_failed',
current_value: 'installed',
required_value: 'Import error'
})
})
})
describe('computed properties', () => {
it('should expose conflict status from store', () => {
mockConflictedPackages.value = [
{
package_id: 'test',
package_name: 'Test',
has_conflict: true,
is_compatible: false,
conflicts: []
}
]
useConflictDetection()
// The hasConflicts computed should be true since we have a conflict
expect(mockConflictedPackages.value).toHaveLength(1)
expect(mockConflictedPackages.value[0].has_conflict).toBe(true)
})
})
describe('initialization', () => {
it('should initialize without errors', async () => {
// Mock that installed packs are ready
mockInstalledPacks.isReady.value = true
mockInstalledPacksWithVersions.value = []
// Ensure startFetchInstalled resolves
vi.mocked(mockInstalledPacks.startFetchInstalled).mockResolvedValue(
undefined
)
const { initializeConflictDetection } = useConflictDetection()
// Set a timeout to prevent hanging
await expect(
Promise.race([
initializeConflictDetection(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 1000)
)
])
).resolves.not.toThrow()
})
})
})

View File

@@ -0,0 +1,251 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import * as dialogService from '@/services/dialogService'
import { useImportFailedDetection } from '@/workbench/extensions/manager/composables/useImportFailedDetection'
import * as comfyManagerStore from '@/workbench/extensions/manager/stores/comfyManagerStore'
import * as conflictDetectionStore from '@/workbench/extensions/manager/stores/conflictDetectionStore'
// Mock the stores and services
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore')
vi.mock('@/workbench/extensions/manager/stores/conflictDetectionStore')
vi.mock('@/services/dialogService')
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual('vue-i18n')
return {
...actual,
useI18n: () => ({
t: vi.fn((key: string) => key)
})
}
})
describe('useImportFailedDetection', () => {
let mockComfyManagerStore: ReturnType<
typeof comfyManagerStore.useComfyManagerStore
>
let mockConflictDetectionStore: ReturnType<
typeof conflictDetectionStore.useConflictDetectionStore
>
let mockDialogService: ReturnType<typeof dialogService.useDialogService>
beforeEach(() => {
setActivePinia(createPinia())
mockComfyManagerStore = {
isPackInstalled: vi.fn()
} as unknown as ReturnType<typeof comfyManagerStore.useComfyManagerStore>
mockConflictDetectionStore = {
getConflictsForPackageByID: vi.fn()
} as unknown as ReturnType<
typeof conflictDetectionStore.useConflictDetectionStore
>
mockDialogService = {
showErrorDialog: vi.fn()
} as unknown as ReturnType<typeof dialogService.useDialogService>
vi.mocked(comfyManagerStore.useComfyManagerStore).mockReturnValue(
mockComfyManagerStore
)
vi.mocked(conflictDetectionStore.useConflictDetectionStore).mockReturnValue(
mockConflictDetectionStore
)
vi.mocked(dialogService.useDialogService).mockReturnValue(mockDialogService)
})
it('should return false for importFailed when package is not installed', () => {
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(false)
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(false)
})
it('should return false for importFailed when no conflicts exist', () => {
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(true)
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue(undefined)
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(false)
})
it('should return false for importFailed when conflicts exist but no import_failed type', () => {
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(true)
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue({
package_id: 'test-package',
package_name: 'Test Package',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'comfyui_version',
current_value: 'current',
required_value: 'required'
},
{
type: 'frontend_version',
current_value: 'current',
required_value: 'required'
}
]
})
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(false)
})
it('should return true for importFailed when import_failed conflicts exist', () => {
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(true)
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue({
package_id: 'test-package',
package_name: 'Test Package',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'import_failed',
current_value: 'current',
required_value: 'Error details'
},
{
type: 'comfyui_version',
current_value: 'current',
required_value: 'required'
}
]
})
const { importFailed } = useImportFailedDetection('test-package')
expect(importFailed.value).toBe(true)
})
it('should work with computed ref packageId', () => {
const packageId = ref('test-package')
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(true)
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue({
package_id: 'test-package',
package_name: 'Test Package',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'import_failed',
current_value: 'current',
required_value: 'Error details'
}
]
})
const { importFailed } = useImportFailedDetection(
computed(() => packageId.value)
)
expect(importFailed.value).toBe(true)
// Change packageId
packageId.value = 'another-package'
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue(undefined)
expect(importFailed.value).toBe(false)
})
it('should return correct importFailedInfo', () => {
const importFailedConflicts = [
{
type: 'import_failed' as const,
current_value: 'current',
required_value: 'Error 1'
},
{
type: 'import_failed' as const,
current_value: 'current',
required_value: 'Error 2'
}
]
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(true)
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue({
package_id: 'test-package',
package_name: 'Test Package',
has_conflict: true,
is_compatible: false,
conflicts: [
...importFailedConflicts,
{
type: 'comfyui_version',
current_value: 'current',
required_value: 'required'
}
]
})
const { importFailedInfo } = useImportFailedDetection('test-package')
expect(importFailedInfo.value).toEqual(importFailedConflicts)
})
it('should show error dialog when showImportFailedDialog is called', () => {
const importFailedConflicts = [
{
type: 'import_failed' as const,
current_value: 'current',
required_value: 'Error details'
}
]
vi.mocked(mockComfyManagerStore.isPackInstalled).mockReturnValue(true)
vi.mocked(
mockConflictDetectionStore.getConflictsForPackageByID
).mockReturnValue({
package_id: 'test-package',
package_name: 'Test Package',
has_conflict: true,
is_compatible: false,
conflicts: importFailedConflicts
})
const { showImportFailedDialog } = useImportFailedDetection('test-package')
showImportFailedDialog()
expect(mockDialogService.showErrorDialog).toHaveBeenCalledWith(
expect.any(Error),
{
title: 'manager.failedToInstall',
reportType: 'importFailedError'
}
)
})
it('should handle null packageId', () => {
const { importFailed, isInstalled } = useImportFailedDetection(null)
expect(importFailed.value).toBe(false)
expect(isInstalled.value).toBe(false)
})
it('should handle undefined packageId', () => {
const { importFailed, isInstalled } = useImportFailedDetection(undefined)
expect(importFailed.value).toBe(false)
expect(isInstalled.value).toBe(false)
})
})

View File

@@ -0,0 +1,222 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
// Mock dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showManagerProgressDialog: vi.fn()
})
}))
// Mock the app API
vi.mock('@/scripts/app', () => ({
app: {
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
clientId: 'test-client-id'
}
}
}))
type ManagerTaskHistory = Record<
string,
components['schemas']['TaskHistoryItem']
>
type ManagerTaskQueue = components['schemas']['TaskStateMessage']
describe('useManagerQueue', () => {
let taskHistory: any
let taskQueue: any
let installedPacks: any
const createManagerQueue = () => {
taskHistory = ref<ManagerTaskHistory>({})
taskQueue = ref<ManagerTaskQueue>({
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {}
})
installedPacks = ref({})
return useManagerQueue(taskHistory, taskQueue, installedPacks)
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('initialization', () => {
it('should initialize with empty state', () => {
const queue = createManagerQueue()
expect(queue.currentQueueLength.value).toBe(0)
expect(queue.allTasksDone.value).toBe(true)
expect(queue.isProcessing.value).toBe(false)
expect(queue.historyCount.value).toBe(0)
})
})
describe('task state management', () => {
it('should track task queue length', () => {
const queue = createManagerQueue()
// Add tasks to queue
taskQueue.value.running_queue = [
{
ui_id: 'task1',
client_id: 'test-client-id',
task_name: 'Installing pack1'
}
]
taskQueue.value.pending_queue = [
{
ui_id: 'task2',
client_id: 'test-client-id',
task_name: 'Installing pack2'
}
]
expect(queue.currentQueueLength.value).toBe(2)
expect(queue.allTasksDone.value).toBe(false)
})
it('should handle empty queues', () => {
const queue = createManagerQueue()
taskQueue.value.running_queue = []
taskQueue.value.pending_queue = []
expect(queue.currentQueueLength.value).toBe(0)
expect(queue.allTasksDone.value).toBe(true)
})
})
describe('task history management', () => {
it('should track task history count', () => {
const queue = createManagerQueue()
taskHistory.value = {
task1: {
ui_id: 'task1',
client_id: 'test-client-id',
status: { status_str: 'success', completed: true }
},
task2: {
ui_id: 'task2',
client_id: 'test-client-id',
status: { status_str: 'success', completed: true }
}
}
expect(queue.historyCount.value).toBe(2)
})
it('should filter tasks by client ID', () => {
const queue = createManagerQueue()
const mockState = {
history: {
task1: {
ui_id: 'task1',
client_id: 'test-client-id', // This client
kind: 'install',
timestamp: '2024-01-01T00:00:00Z',
result: 'success',
status: {
status_str: 'success' as const,
completed: true,
messages: []
}
},
task2: {
ui_id: 'task2',
client_id: 'other-client-id', // Different client
kind: 'install',
timestamp: '2024-01-01T00:00:00Z',
result: 'success',
status: {
status_str: 'success' as const,
completed: true,
messages: []
}
}
},
running_queue: [],
pending_queue: [],
installed_packs: {}
}
queue.updateTaskState(mockState)
// Should only include task from this client
expect(taskHistory.value).toHaveProperty('task1')
expect(taskHistory.value).not.toHaveProperty('task2')
})
it('normalizes pack IDs when updating installed packs', () => {
const queue = createManagerQueue()
const mockState = {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {
'ComfyUI-GGUF@1_1_4': {
enabled: false,
cnr_id: 'ComfyUI-GGUF',
ver: '1.1.4'
},
'test-pack': {
enabled: true,
cnr_id: 'test-pack',
ver: '2.0.0'
}
}
}
queue.updateTaskState(mockState)
// Packs should be accessible by normalized keys
expect(installedPacks.value['ComfyUI-GGUF']).toEqual({
enabled: false,
cnr_id: 'ComfyUI-GGUF',
ver: '1.1.4'
})
expect(installedPacks.value['test-pack']).toEqual({
enabled: true,
cnr_id: 'test-pack',
ver: '2.0.0'
})
// Version suffixed keys should not exist after normalization
// The pack should be accessible by its base name only (without @version)
expect(installedPacks.value['ComfyUI-GGUF@1_1_4']).toBeUndefined()
})
it('handles empty installed_packs gracefully', () => {
const queue = createManagerQueue()
const mockState: any = {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: undefined
}
// Just call the function - if it throws, the test will fail automatically
queue.updateTaskState(mockState)
// installedPacks should remain unchanged
expect(installedPacks.value).toEqual({})
})
})
})

View File

@@ -0,0 +1,356 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { api } from '@/scripts/api'
import { useExtensionStore } from '@/stores/extensionStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import {
ManagerUIState,
useManagerState
} from '@/workbench/extensions/manager/composables/useManagerState'
// Mock dependencies
vi.mock('@/scripts/api', () => ({
api: {
getClientFeatureFlags: vi.fn(),
getServerFeature: vi.fn()
}
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
}))
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: vi.fn()
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showManagerPopup: vi.fn(),
showLegacyManagerPopup: vi.fn(),
showSettingsDialog: vi.fn()
}))
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn(() => ({
execute: vi.fn()
}))
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: vi.fn()
}))
}))
describe('useManagerState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('managerUIState property', () => {
it('should return DISABLED state when --enable-manager is NOT present', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py'] } // No --enable-manager flag
}),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
})
it('should return LEGACY_UI state when --enable-manager-legacy-ui is present', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: {
argv: [
'python',
'main.py',
'--enable-manager',
'--enable-manager-legacy-ui'
]
} // Both flags needed
}),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return NEW_UI state when client and server both support v4', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI)
})
it('should return LEGACY_UI state when server supports v4 but client does not', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: false
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return LEGACY_UI state when legacy manager extension exists', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: [{ name: 'Comfy.CustomNodesManager' }]
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return NEW_UI state when server feature flags are undefined', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(undefined)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: undefined },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI)
})
it('should return LEGACY_UI state when server does not support v4', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(false)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should handle null systemStats gracefully', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref(null),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
// When systemStats is null, we can't check for --enable-manager flag, so manager is disabled
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
})
})
describe('helper properties', () => {
it('isManagerEnabled should return true when state is not DISABLED', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.isManagerEnabled.value).toBe(true)
})
it('isManagerEnabled should return false when state is DISABLED', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py'] } // No --enable-manager flag means disabled
}),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.isManagerEnabled.value).toBe(false)
})
it('isNewManagerUI should return true when state is NEW_UI', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.isNewManagerUI.value).toBe(true)
})
it('isLegacyManagerUI should return true when state is LEGACY_UI', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: {
argv: [
'python',
'main.py',
'--enable-manager',
'--enable-manager-legacy-ui'
]
} // Both flags needed
}),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.isLegacyManagerUI.value).toBe(true)
})
it('shouldShowInstallButton should return true only for NEW_UI', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.shouldShowInstallButton.value).toBe(true)
})
it('shouldShowManagerButtons should return true when not DISABLED', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.shouldShowManagerButtons.value).toBe(true)
})
})
})

View File

@@ -0,0 +1,536 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { components as ManagerComponents } from '@/workbench/extensions/manager/types/generatedManagerTypes'
type InstalledPacksResponse =
ManagerComponents['schemas']['InstalledPacksResponse']
type ManagerChannel = ManagerComponents['schemas']['ManagerChannel']
type ManagerDatabaseSource =
ManagerComponents['schemas']['ManagerDatabaseSource']
type ManagerPackInstalled = ManagerComponents['schemas']['ManagerPackInstalled']
vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
useComfyManagerService: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showManagerProgressDialog: vi.fn()
})
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerQueue', () => {
const enqueueTaskMock = vi.fn()
return {
useManagerQueue: () => ({
statusMessage: ref(''),
allTasksDone: ref(false),
enqueueTask: enqueueTaskMock,
isProcessingTasks: ref(false)
}),
enqueueTask: enqueueTaskMock
}
})
vi.mock('@/composables/useServerLogs', () => ({
useServerLogs: () => ({
startListening: vi.fn(),
stopListening: vi.fn(),
logs: ref([])
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key) => key)
}),
createI18n: vi.fn(() => ({
global: {
t: vi.fn((key) => key)
}
}))
}))
interface EnabledDisabledTestCase {
desc: string
installed: Record<string, ManagerPackInstalled>
expectState: 'enabled' | 'disabled'
/** @default 'name' */
packName?: string
}
describe('useComfyManagerStore', () => {
let mockManagerService: ReturnType<typeof useComfyManagerService>
const triggerPacksChange = async (
installedPacks: InstalledPacksResponse,
store: ReturnType<typeof useComfyManagerStore>
) => {
// Simulate change in value to properly trigger watchers. Required even for immediate watchers.
store.installedPacks = {}
await nextTick()
store.installedPacks = installedPacks
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockManagerService = {
isLoading: ref(false),
error: ref(null),
startQueue: vi.fn().mockResolvedValue(null),
getQueueStatus: vi.fn().mockResolvedValue(null),
getTaskHistory: vi.fn().mockResolvedValue(null),
listInstalledPacks: vi.fn().mockResolvedValue({}),
getImportFailInfo: vi.fn().mockResolvedValue(null),
getImportFailInfoBulk: vi.fn().mockResolvedValue({}),
installPack: vi.fn().mockResolvedValue(null),
uninstallPack: vi.fn().mockResolvedValue(null),
enablePack: vi.fn().mockResolvedValue(null),
disablePack: vi.fn().mockResolvedValue(null),
updatePack: vi.fn().mockResolvedValue(null),
updateAllPacks: vi.fn().mockResolvedValue(null),
updateComfyUI: vi.fn().mockResolvedValue(null),
rebootComfyUI: vi.fn().mockResolvedValue(null),
isLegacyManagerUI: vi.fn().mockResolvedValue(false)
}
vi.mocked(useComfyManagerService).mockReturnValue(mockManagerService)
})
const testCases: EnabledDisabledTestCase[] = [
{
desc: 'Two enabled versions',
installed: {
'name@1_0_2': {
enabled: true,
cnr_id: 'name',
ver: '1.0.2',
aux_id: undefined
},
name: { enabled: true, cnr_id: 'name', ver: '1.0.0', aux_id: undefined }
},
expectState: 'enabled'
},
{
desc: 'Two disabled versions',
installed: {
'name@1_0_2': {
enabled: false,
cnr_id: 'name',
ver: '1.0.2',
aux_id: undefined
},
name: {
enabled: false,
cnr_id: 'name',
ver: '1.0.0',
aux_id: undefined
}
},
expectState: 'disabled'
},
{
desc: 'Enabled version and pinned disabled version',
installed: {
'name@1_0_2': {
enabled: false,
cnr_id: 'name',
ver: '1.0.2',
aux_id: undefined
},
name: { enabled: true, cnr_id: 'name', ver: '1.0.0', aux_id: undefined }
},
expectState: 'enabled'
},
{
desc: 'Disabled version and pinned enabled version',
installed: {
'name@1_0_2': {
enabled: true,
cnr_id: 'name',
ver: '1.0.2',
aux_id: undefined
},
name: {
enabled: false,
cnr_id: 'name',
ver: '1.0.0',
aux_id: undefined
}
},
expectState: 'enabled'
},
{
desc: 'Pinned enabled version, Pinned disabled version',
installed: {
'name@1_0_2': {
enabled: false,
cnr_id: 'name',
ver: '1.0.2',
aux_id: undefined
},
'name@1_0_3': {
enabled: true,
cnr_id: 'name',
ver: '1.0.3',
aux_id: undefined
}
},
expectState: 'enabled'
},
{
desc: 'Two enabled non-CNR versions',
packName: 'author/name',
installed: {
'author/name@1_0_2': {
enabled: true,
aux_id: 'author/name',
cnr_id: undefined,
ver: '1.0.2'
},
'author/name': {
enabled: true,
aux_id: 'author/name',
cnr_id: undefined,
ver: '1.0.0'
}
},
expectState: 'enabled'
},
{
desc: 'Two disabled non-CNR versions',
packName: 'author/name',
installed: {
'author/name@1_0_2': {
enabled: false,
aux_id: 'author/name',
cnr_id: undefined,
ver: '1.0.2'
},
'author/name': {
enabled: false,
aux_id: 'author/name',
cnr_id: undefined,
ver: '1.0.0'
}
},
expectState: 'disabled'
},
{
desc: 'Non-CNR disabled version, CNR enabled version',
packName: 'author/name',
installed: {
'author/name': {
enabled: false,
aux_id: 'author/name',
cnr_id: undefined,
ver: '1.0.0'
},
'author/name@1_0_2': {
enabled: true,
cnr_id: 'author/name',
ver: '1.0.2',
aux_id: undefined
}
},
expectState: 'enabled'
},
{
desc: 'Disabled non-CNR version, CNR disabled version',
packName: 'author/name',
installed: {
'author/name': {
enabled: false,
aux_id: 'author/name',
cnr_id: undefined, // non-CNR pack
ver: '1.0.0'
},
'author/name@1_0_2': {
enabled: false,
cnr_id: 'author/name', // CNR pack
ver: '1.0.2',
aux_id: undefined
}
},
expectState: 'disabled'
},
{
desc: 'Enabled non-CNR version, two versions',
packName: 'author/name',
installed: {
'author/name': {
enabled: true,
aux_id: 'author/name',
cnr_id: undefined,
ver: '1.0.0'
},
'author/name@1_0_2': {
enabled: true,
cnr_id: 'author/name',
ver: '1.0.2',
aux_id: undefined
}
},
expectState: 'enabled'
},
{
desc: 'Enabled CNR version',
packName: 'name',
installed: {
name: { enabled: true, cnr_id: 'name', ver: '1.0.0', aux_id: undefined }
},
expectState: 'enabled'
},
{
desc: 'Disabled CNR version',
packName: 'name',
installed: {
name: {
enabled: false,
cnr_id: 'name',
ver: '1.0.0',
aux_id: undefined
}
},
expectState: 'disabled'
},
{
desc: 'Enabled non-CNR version',
packName: 'author/name',
installed: {
'author/name': {
enabled: true,
aux_id: 'author/name',
cnr_id: undefined,
ver: '1.0.0'
}
},
expectState: 'enabled'
},
{
desc: 'Disabled non-CNR version',
packName: 'author/name',
installed: {
'author/name': {
enabled: false,
aux_id: 'author/name',
cnr_id: undefined,
ver: '1.0.0'
}
},
expectState: 'disabled'
},
{
desc: 'Pack not installed',
installed: {
'a different pack': {
enabled: true,
cnr_id: 'a different pack',
ver: '1.0.0',
aux_id: undefined
}
},
expectState: 'disabled'
}
]
describe('isPackEnabled', () => {
it.each(testCases)(
'$expectState when $desc',
async ({ installed, expectState, packName }) => {
packName ??= 'name'
const store = useComfyManagerStore()
await triggerPacksChange(installed, store)
const enabled = expectState === 'enabled'
expect(store.isPackEnabled(packName)).toBe(enabled)
}
)
})
describe.skip('isPackInstalling', () => {
it('should return false for packs not being installed', () => {
const store = useComfyManagerStore()
expect(store.isPackInstalling('test-pack')).toBe(false)
expect(store.isPackInstalling(undefined)).toBe(false)
expect(store.isPackInstalling('')).toBe(false)
})
it('should track pack as installing when installPack is called', async () => {
const store = useComfyManagerStore()
// Call installPack
await store.installPack.call({
id: 'test-pack',
repository: 'https://github.com/test/test-pack',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
// Check that the pack is marked as installing
expect(store.isPackInstalling('test-pack')).toBe(true)
})
it('should remove pack from installing list when explicitly removed', async () => {
const store = useComfyManagerStore()
// Call installPack
await store.installPack.call({
id: 'test-pack',
repository: 'https://github.com/test/test-pack',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
// Verify pack is installing
expect(store.isPackInstalling('test-pack')).toBe(true)
// Call installPack again for another pack to demonstrate multiple installs
await store.installPack.call({
id: 'another-pack',
repository: 'https://github.com/test/another-pack',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
// Both should be installing
expect(store.isPackInstalling('test-pack')).toBe(true)
expect(store.isPackInstalling('another-pack')).toBe(true)
})
it('should track multiple packs installing independently', async () => {
const store = useComfyManagerStore()
// Install pack 1
await store.installPack.call({
id: 'pack-1',
repository: 'https://github.com/test/pack-1',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
// Install pack 2
await store.installPack.call({
id: 'pack-2',
repository: 'https://github.com/test/pack-2',
channel: 'dev' as ManagerChannel,
mode: 'cache' as ManagerDatabaseSource,
selected_version: 'latest',
version: 'latest'
})
// Both should be installing
expect(store.isPackInstalling('pack-1')).toBe(true)
expect(store.isPackInstalling('pack-2')).toBe(true)
expect(store.isPackInstalling('pack-3')).toBe(false)
})
})
describe('refreshInstalledList with pack ID normalization', () => {
it('normalizes pack IDs by removing version suffixes', async () => {
const mockPacks = {
'ComfyUI-GGUF@1_1_4': {
enabled: false,
cnr_id: 'ComfyUI-GGUF',
ver: '1.1.4',
aux_id: undefined
},
'ComfyUI-Manager': {
enabled: true,
cnr_id: 'ComfyUI-Manager',
ver: '2.0.0',
aux_id: undefined
}
}
vi.mocked(mockManagerService.listInstalledPacks).mockResolvedValue(
mockPacks
)
const store = useComfyManagerStore()
await store.refreshInstalledList()
// Both packs should be accessible by their base name
expect(store.installedPacks['ComfyUI-GGUF']).toEqual({
enabled: false,
cnr_id: 'ComfyUI-GGUF',
ver: '1.1.4',
aux_id: undefined
})
expect(store.installedPacks['ComfyUI-Manager']).toEqual({
enabled: true,
cnr_id: 'ComfyUI-Manager',
ver: '2.0.0',
aux_id: undefined
})
// Version suffixed keys should not exist
expect(store.installedPacks['ComfyUI-GGUF@1_1_4']).toBeUndefined()
})
it('handles duplicate keys after normalization', async () => {
const mockPacks = {
'test-pack': {
enabled: true,
cnr_id: 'test-pack',
ver: '1.0.0',
aux_id: undefined
},
'test-pack@1_1_0': {
enabled: false,
cnr_id: 'test-pack',
ver: '1.1.0',
aux_id: undefined
}
}
vi.mocked(mockManagerService.listInstalledPacks).mockResolvedValue(
mockPacks
)
const store = useComfyManagerStore()
await store.refreshInstalledList()
// The normalized key should exist (last one wins with mapKeys)
expect(store.installedPacks['test-pack']).toBeDefined()
expect(store.installedPacks['test-pack'].ver).toBe('1.1.0')
})
it('preserves version information for disabled packs', async () => {
const mockPacks = {
'disabled-pack@2_0_0': {
enabled: false,
cnr_id: 'disabled-pack',
ver: '2.0.0',
aux_id: undefined
}
}
vi.mocked(mockManagerService.listInstalledPacks).mockResolvedValue(
mockPacks
)
const store = useComfyManagerStore()
await store.refreshInstalledList()
// Pack should be accessible by base name with version preserved
expect(store.getInstalledPackVersion('disabled-pack')).toBe('2.0.0')
expect(store.isPackInstalled('disabled-pack')).toBe(true)
})
})
})

View File

@@ -0,0 +1,271 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useConflictDetectionStore } from '@/workbench/extensions/manager/stores/conflictDetectionStore'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
describe('useConflictDetectionStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
const mockConflictedPackages: ConflictDetectionResult[] = [
{
package_id: 'ComfyUI-Manager',
package_name: 'ComfyUI-Manager',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'pending',
current_value: 'no_registry_data',
required_value: 'registry_data_available'
}
]
},
{
package_id: 'comfyui-easy-use',
package_name: 'comfyui-easy-use',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'comfyui_version',
current_value: '0.3.43',
required_value: '<0.3.40'
}
]
},
{
package_id: 'img2colors-comfyui-node',
package_name: 'img2colors-comfyui-node',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
}
]
}
]
describe('initial state', () => {
it('should have empty initial state', () => {
const store = useConflictDetectionStore()
expect(store.conflictedPackages).toEqual([])
expect(store.isDetecting).toBe(false)
expect(store.lastDetectionTime).toBeNull()
expect(store.hasConflicts).toBe(false)
})
})
describe('setConflictedPackages', () => {
it('should set conflicted packages', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages(mockConflictedPackages)
expect(store.conflictedPackages).toEqual(mockConflictedPackages)
expect(store.conflictedPackages).toHaveLength(3)
})
it('should update hasConflicts computed property', () => {
const store = useConflictDetectionStore()
expect(store.hasConflicts).toBe(false)
store.setConflictedPackages(mockConflictedPackages)
expect(store.hasConflicts).toBe(true)
})
})
describe('getConflictsForPackageByID', () => {
it('should find package by exact ID match', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages(mockConflictedPackages)
const result = store.getConflictsForPackageByID('ComfyUI-Manager')
expect(result).toBeDefined()
expect(result?.package_id).toBe('ComfyUI-Manager')
expect(result?.conflicts).toHaveLength(1)
})
it('should return undefined for non-existent package', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages(mockConflictedPackages)
const result = store.getConflictsForPackageByID('non-existent-package')
expect(result).toBeUndefined()
})
})
describe('bannedPackages', () => {
it('should filter packages with banned conflicts', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages(mockConflictedPackages)
const bannedPackages = store.bannedPackages
expect(bannedPackages).toHaveLength(1)
expect(bannedPackages[0].package_id).toBe('img2colors-comfyui-node')
})
it('should return empty array when no banned packages', () => {
const store = useConflictDetectionStore()
const noBannedPackages = mockConflictedPackages.filter(
(pkg) => !pkg.conflicts.some((c) => c.type === 'banned')
)
store.setConflictedPackages(noBannedPackages)
const bannedPackages = store.bannedPackages
expect(bannedPackages).toHaveLength(0)
})
})
describe('securityPendingPackages', () => {
it('should filter packages with pending conflicts', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages(mockConflictedPackages)
const securityPendingPackages = store.securityPendingPackages
expect(securityPendingPackages).toHaveLength(1)
expect(securityPendingPackages[0].package_id).toBe('ComfyUI-Manager')
})
})
describe('clearConflicts', () => {
it('should clear all conflicted packages', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages(mockConflictedPackages)
expect(store.conflictedPackages).toHaveLength(3)
expect(store.hasConflicts).toBe(true)
store.clearConflicts()
expect(store.conflictedPackages).toEqual([])
expect(store.hasConflicts).toBe(false)
})
})
describe('detection state management', () => {
it('should set detecting state', () => {
const store = useConflictDetectionStore()
expect(store.isDetecting).toBe(false)
store.setDetecting(true)
expect(store.isDetecting).toBe(true)
store.setDetecting(false)
expect(store.isDetecting).toBe(false)
})
it('should set last detection time', () => {
const store = useConflictDetectionStore()
const timestamp = '2024-01-01T00:00:00Z'
expect(store.lastDetectionTime).toBeNull()
store.setLastDetectionTime(timestamp)
expect(store.lastDetectionTime).toBe(timestamp)
})
})
describe('reactivity', () => {
it('should update computed properties when conflicted packages change', () => {
const store = useConflictDetectionStore()
// Initially no conflicts
expect(store.hasConflicts).toBe(false)
expect(store.bannedPackages).toHaveLength(0)
// Add conflicts
store.setConflictedPackages(mockConflictedPackages)
// Computed properties should update
expect(store.hasConflicts).toBe(true)
expect(store.bannedPackages).toHaveLength(1)
expect(store.securityPendingPackages).toHaveLength(1)
// Clear conflicts
store.clearConflicts()
// Computed properties should update again
expect(store.hasConflicts).toBe(false)
expect(store.bannedPackages).toHaveLength(0)
expect(store.securityPendingPackages).toHaveLength(0)
})
})
describe('edge cases', () => {
it('should handle empty conflicts array', () => {
const store = useConflictDetectionStore()
store.setConflictedPackages([])
expect(store.conflictedPackages).toEqual([])
expect(store.hasConflicts).toBe(false)
expect(store.bannedPackages).toHaveLength(0)
expect(store.securityPendingPackages).toHaveLength(0)
})
it('should handle packages with multiple conflict types', () => {
const store = useConflictDetectionStore()
const multiConflictPackage: ConflictDetectionResult = {
package_id: 'multi-conflict-package',
package_name: 'Multi Conflict Package',
has_conflict: true,
is_compatible: false,
conflicts: [
{
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
},
{
type: 'pending',
current_value: 'no_registry_data',
required_value: 'registry_data_available'
}
]
}
store.setConflictedPackages([multiConflictPackage])
// Should appear in both banned and security pending
expect(store.bannedPackages).toHaveLength(1)
expect(store.securityPendingPackages).toHaveLength(1)
expect(store.bannedPackages[0].package_id).toBe('multi-conflict-package')
expect(store.securityPendingPackages[0].package_id).toBe(
'multi-conflict-package'
)
})
it('should handle packages with has_conflict false', () => {
const store = useConflictDetectionStore()
const noConflictPackage: ConflictDetectionResult = {
package_id: 'no-conflict-package',
package_name: 'No Conflict Package',
has_conflict: false,
is_compatible: true,
conflicts: []
}
store.setConflictedPackages([noConflictPackage])
// hasConflicts should check has_conflict property
expect(store.hasConflicts).toBe(false)
})
})
})

View File

@@ -0,0 +1,207 @@
import { describe, expect, it } from 'vitest'
import type {
ConflictDetail,
ConflictDetectionResult
} from '@/workbench/extensions/manager/types/conflictDetectionTypes'
import {
consolidateConflictsByPackage,
createBannedConflict,
createPendingConflict
} from '@/workbench/extensions/manager/utils/conflictUtils'
describe('conflictUtils', () => {
describe('createBannedConflict', () => {
it('should return banned conflict when isBanned is true', () => {
const result = createBannedConflict(true)
expect(result).toEqual({
type: 'banned',
current_value: 'installed',
required_value: 'not_banned'
})
})
it('should return null when isBanned is false', () => {
const result = createBannedConflict(false)
expect(result).toBeNull()
})
it('should return null when isBanned is undefined', () => {
const result = createBannedConflict(undefined)
expect(result).toBeNull()
})
})
describe('createPendingConflict', () => {
it('should return pending conflict when isPending is true', () => {
const result = createPendingConflict(true)
expect(result).toEqual({
type: 'pending',
current_value: 'installed',
required_value: 'not_pending'
})
})
it('should return null when isPending is false', () => {
const result = createPendingConflict(false)
expect(result).toBeNull()
})
it('should return null when isPending is undefined', () => {
const result = createPendingConflict(undefined)
expect(result).toBeNull()
})
})
describe('consolidateConflictsByPackage', () => {
it('should group conflicts by normalized package name', () => {
const conflicts: ConflictDetectionResult[] = [
{
package_name: 'mypack@1_0_3',
package_id: 'mypack@1_0_3',
conflicts: [
{ type: 'os', current_value: 'Windows', required_value: 'Linux' }
],
has_conflict: true,
is_compatible: false
},
{
package_name: 'mypack',
package_id: 'mypack',
conflicts: [
{
type: 'comfyui_version',
current_value: '1.0.0',
required_value: '>=2.0.0'
}
],
has_conflict: true,
is_compatible: false
}
]
const result = consolidateConflictsByPackage(conflicts)
expect(result).toHaveLength(1)
expect(result[0].package_name).toBe('mypack')
expect(result[0].conflicts).toHaveLength(2)
expect(result[0].has_conflict).toBe(true)
expect(result[0].is_compatible).toBe(false)
})
it('should deduplicate identical conflicts', () => {
const duplicateConflict: ConflictDetail = {
type: 'os',
current_value: 'Windows',
required_value: 'Linux'
}
const conflicts: ConflictDetectionResult[] = [
{
package_name: 'pack',
package_id: 'pack',
conflicts: [duplicateConflict],
has_conflict: true,
is_compatible: false
},
{
package_name: 'pack@version',
package_id: 'pack@version',
conflicts: [duplicateConflict],
has_conflict: true,
is_compatible: false
}
]
const result = consolidateConflictsByPackage(conflicts)
expect(result).toHaveLength(1)
expect(result[0].conflicts).toHaveLength(1)
})
it('should handle packages without conflicts', () => {
const conflicts: ConflictDetectionResult[] = [
{
package_name: 'compatible-pack',
package_id: 'compatible-pack',
conflicts: [],
has_conflict: false,
is_compatible: true
}
]
const result = consolidateConflictsByPackage(conflicts)
expect(result).toHaveLength(1)
expect(result[0].conflicts).toHaveLength(0)
expect(result[0].has_conflict).toBe(false)
expect(result[0].is_compatible).toBe(true)
})
it('should handle empty input', () => {
const result = consolidateConflictsByPackage([])
expect(result).toEqual([])
})
it('should merge conflicts from multiple versions of same package', () => {
const conflicts: ConflictDetectionResult[] = [
{
package_name: 'mynode@1_0_0',
package_id: 'mynode@1_0_0',
conflicts: [
{ type: 'os', current_value: 'Windows', required_value: 'Linux' }
],
has_conflict: true,
is_compatible: false
},
{
package_name: 'mynode@2_0_0',
package_id: 'mynode@2_0_0',
conflicts: [
{
type: 'accelerator',
current_value: 'CPU',
required_value: 'CUDA'
}
],
has_conflict: true,
is_compatible: false
},
{
package_name: 'mynode',
package_id: 'mynode',
conflicts: [
{
type: 'comfyui_version',
current_value: '1.0.0',
required_value: '>=2.0.0'
}
],
has_conflict: true,
is_compatible: false
}
]
const result = consolidateConflictsByPackage(conflicts)
expect(result).toHaveLength(1)
expect(result[0].package_name).toBe('mynode')
expect(result[0].conflicts).toHaveLength(3)
expect(result[0].conflicts).toContainEqual({
type: 'os',
current_value: 'Windows',
required_value: 'Linux'
})
expect(result[0].conflicts).toContainEqual({
type: 'accelerator',
current_value: 'CPU',
required_value: 'CUDA'
})
expect(result[0].conflicts).toContainEqual({
type: 'comfyui_version',
current_value: '1.0.0',
required_value: '>=2.0.0'
})
})
})
})

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import type {
LGraph,
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import {
collectMissingNodes,
graphHasMissingNodes
} from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import type { NodeDefLookup } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type NodeDefs = NodeDefLookup
let nodeIdCounter = 0
const mockNodeDef = {} as ComfyNodeDefImpl
const createGraph = (nodes: LGraphNode[] = []): LGraph => {
return { nodes } as Partial<LGraph> as LGraph
}
const createSubgraph = (nodes: LGraphNode[]): Subgraph => {
return { nodes } as Partial<Subgraph> as Subgraph
}
const createNode = (
type?: string,
subgraphNodes?: LGraphNode[]
): LGraphNode => {
return {
id: nodeIdCounter++,
type,
isSubgraphNode: subgraphNodes ? () => true : undefined,
subgraph: subgraphNodes ? createSubgraph(subgraphNodes) : undefined
} as unknown as LGraphNode
}
describe('graphHasMissingNodes', () => {
it('returns false when graph is null', () => {
expect(graphHasMissingNodes(null, {})).toBe(false)
})
it('returns false when graph is undefined', () => {
expect(graphHasMissingNodes(undefined, {})).toBe(false)
})
it('returns false when graph has no nodes', () => {
expect(graphHasMissingNodes(createGraph(), {})).toBe(false)
})
it('returns false when every node has a definition', () => {
const graph = createGraph([createNode('FooNode'), createNode('BarNode')])
const nodeDefs: NodeDefs = {
FooNode: mockNodeDef,
BarNode: mockNodeDef
}
expect(graphHasMissingNodes(graph, nodeDefs)).toBe(false)
})
it('returns true when at least one node is missing', () => {
const graph = createGraph([
createNode('FooNode'),
createNode('MissingNode')
])
const nodeDefs: NodeDefs = {
FooNode: mockNodeDef
}
expect(graphHasMissingNodes(graph, nodeDefs)).toBe(true)
})
it('checks nodes nested in subgraphs', () => {
const graph = createGraph([
createNode('ContainerNode', [createNode('InnerMissing')])
])
const nodeDefs: NodeDefs = {
ContainerNode: mockNodeDef
}
const missingNodes = collectMissingNodes(graph, nodeDefs)
expect(missingNodes).toHaveLength(1)
expect(missingNodes[0]?.type).toBe('InnerMissing')
})
it('ignores nodes without a type', () => {
const graph = createGraph([
createNode(undefined),
createNode(null as unknown as string)
])
expect(graphHasMissingNodes(graph, {})).toBe(false)
})
it('traverses deeply nested subgraphs', () => {
const deepGraph = createGraph([
createNode('Layer1', [
createNode('Layer2', [
createNode('Layer3', [createNode('MissingDeep')])
])
])
])
const nodeDefs: NodeDefs = {
Layer1: mockNodeDef,
Layer2: mockNodeDef,
Layer3: mockNodeDef
}
const missingNodes = collectMissingNodes(deepGraph, nodeDefs)
expect(missingNodes).toHaveLength(1)
expect(missingNodes[0]?.type).toBe('MissingDeep')
})
})

View File

@@ -0,0 +1,270 @@
import { describe, expect, it } from 'vitest'
import type {
RegistryAccelerator,
RegistryOS
} from '@/workbench/extensions/manager/types/compatibility.types'
import {
checkAcceleratorCompatibility,
checkOSCompatibility,
normalizeOSList
} from '@/workbench/extensions/manager/utils/systemCompatibility'
describe('systemCompatibility', () => {
describe('checkOSCompatibility', () => {
it('should return null when supported OS list is null', () => {
const result = checkOSCompatibility(null, 'darwin')
expect(result).toBeNull()
})
it('should return null when supported OS list is undefined', () => {
const result = checkOSCompatibility(undefined, 'darwin')
expect(result).toBeNull()
})
it('should return null when supported OS list is empty', () => {
const result = checkOSCompatibility([], 'darwin')
expect(result).toBeNull()
})
it('should return null when OS is compatible (macOS)', () => {
const supported: RegistryOS[] = ['macOS', 'Windows']
const result = checkOSCompatibility(supported, 'darwin')
expect(result).toBeNull()
})
it('should return null when OS is compatible (Windows)', () => {
const supported: RegistryOS[] = ['Windows', 'Linux']
const result = checkOSCompatibility(supported, 'win32')
expect(result).toBeNull()
})
it('should return null when OS is compatible (Linux)', () => {
const supported: RegistryOS[] = ['Linux', 'macOS']
const result = checkOSCompatibility(supported, 'linux')
expect(result).toBeNull()
})
it('should return conflict when OS is incompatible', () => {
const supported: RegistryOS[] = ['Windows']
const result = checkOSCompatibility(supported, 'darwin')
expect(result).toEqual({
type: 'os',
current_value: 'macOS',
required_value: 'Windows'
})
})
it('should return conflict with Unknown OS when current OS is unrecognized', () => {
const supported: RegistryOS[] = ['Windows', 'Linux']
const result = checkOSCompatibility(supported, 'freebsd')
expect(result).toEqual({
type: 'os',
current_value: 'Unknown',
required_value: 'Windows, Linux'
})
})
it('should handle various OS string formats', () => {
const supported: RegistryOS[] = ['Windows']
// Test Windows variations
expect(checkOSCompatibility(supported, 'win32')).toBeNull()
expect(checkOSCompatibility(supported, 'windows')).toBeNull()
expect(checkOSCompatibility(supported, 'Windows_NT')).toBeNull()
// Test macOS variations
const macSupported: RegistryOS[] = ['macOS']
expect(checkOSCompatibility(macSupported, 'darwin')).toBeNull()
expect(checkOSCompatibility(macSupported, 'Darwin')).toBeNull()
expect(checkOSCompatibility(macSupported, 'macos')).toBeNull()
expect(checkOSCompatibility(macSupported, 'mac')).toBeNull()
})
it('should handle undefined current OS', () => {
const supported: RegistryOS[] = ['Windows']
const result = checkOSCompatibility(supported, undefined)
expect(result).toEqual({
type: 'os',
current_value: 'Unknown',
required_value: 'Windows'
})
})
})
describe('checkAcceleratorCompatibility', () => {
it('should return null when supported accelerator list is null', () => {
const result = checkAcceleratorCompatibility(null, 'cuda')
expect(result).toBeNull()
})
it('should return null when supported accelerator list is undefined', () => {
const result = checkAcceleratorCompatibility(undefined, 'cuda')
expect(result).toBeNull()
})
it('should return null when supported accelerator list is empty', () => {
const result = checkAcceleratorCompatibility([], 'cuda')
expect(result).toBeNull()
})
it('should return null when accelerator is compatible (CUDA)', () => {
const supported: RegistryAccelerator[] = ['CUDA', 'CPU']
const result = checkAcceleratorCompatibility(supported, 'cuda')
expect(result).toBeNull()
})
it('should return null when accelerator is compatible (Metal)', () => {
const supported: RegistryAccelerator[] = ['Metal', 'CPU']
const result = checkAcceleratorCompatibility(supported, 'mps')
expect(result).toBeNull()
})
it('should return null when accelerator is compatible (ROCm)', () => {
const supported: RegistryAccelerator[] = ['ROCm', 'CPU']
const result = checkAcceleratorCompatibility(supported, 'rocm')
expect(result).toBeNull()
})
it('should return null when accelerator is compatible (CPU)', () => {
const supported: RegistryAccelerator[] = ['CPU']
const result = checkAcceleratorCompatibility(supported, 'cpu')
expect(result).toBeNull()
})
it('should return conflict when accelerator is incompatible', () => {
const supported: RegistryAccelerator[] = ['CUDA']
const result = checkAcceleratorCompatibility(supported, 'mps')
expect(result).toEqual({
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA'
})
})
it('should default to CPU for unknown device types', () => {
const supported: RegistryAccelerator[] = ['CUDA']
const result = checkAcceleratorCompatibility(supported, 'unknown')
expect(result).toEqual({
type: 'accelerator',
current_value: 'CPU',
required_value: 'CUDA'
})
})
it('should default to CPU when device type is undefined', () => {
const supported: RegistryAccelerator[] = ['CUDA']
const result = checkAcceleratorCompatibility(supported, undefined)
expect(result).toEqual({
type: 'accelerator',
current_value: 'CPU',
required_value: 'CUDA'
})
})
it('should handle case-insensitive device types', () => {
const supported: RegistryAccelerator[] = ['CUDA']
// CUDA variations
expect(checkAcceleratorCompatibility(supported, 'cuda')).toBeNull()
expect(checkAcceleratorCompatibility(supported, 'CUDA')).toBeNull()
expect(checkAcceleratorCompatibility(supported, 'Cuda')).toBeNull()
// Metal variations
const metalSupported: RegistryAccelerator[] = ['Metal']
expect(checkAcceleratorCompatibility(metalSupported, 'mps')).toBeNull()
expect(checkAcceleratorCompatibility(metalSupported, 'MPS')).toBeNull()
// ROCm variations
const rocmSupported: RegistryAccelerator[] = ['ROCm']
expect(checkAcceleratorCompatibility(rocmSupported, 'rocm')).toBeNull()
expect(checkAcceleratorCompatibility(rocmSupported, 'ROCM')).toBeNull()
})
it('should handle multiple required accelerators', () => {
const supported: RegistryAccelerator[] = ['CUDA', 'ROCm']
const result = checkAcceleratorCompatibility(supported, 'mps')
expect(result).toEqual({
type: 'accelerator',
current_value: 'Metal',
required_value: 'CUDA, ROCm'
})
})
})
describe('normalizeOSList', () => {
it('should return undefined for null input', () => {
const result = normalizeOSList(null)
expect(result).toBeUndefined()
})
it('should return undefined for undefined input', () => {
const result = normalizeOSList(undefined)
expect(result).toBeUndefined()
})
it('should return undefined for empty array', () => {
const result = normalizeOSList([])
expect(result).toBeUndefined()
})
it('should return undefined when OS Independent is present', () => {
const result = normalizeOSList(['OS Independent', 'Windows'])
expect(result).toBeUndefined()
})
it('should return undefined for case-insensitive OS Independent', () => {
const result = normalizeOSList(['os independent'])
expect(result).toBeUndefined()
})
it('should filter and return valid OS values', () => {
const result = normalizeOSList(['Windows', 'Linux', 'macOS'])
expect(result).toEqual(['Windows', 'Linux', 'macOS'])
})
it('should filter out invalid OS values', () => {
const result = normalizeOSList(['Windows', 'FreeBSD', 'Linux', 'Android'])
expect(result).toEqual(['Windows', 'Linux'])
})
it('should deduplicate OS values', () => {
const result = normalizeOSList([
'Windows',
'Linux',
'Windows',
'macOS',
'Linux'
])
expect(result).toEqual(['Windows', 'Linux', 'macOS'])
})
it('should return undefined when no valid OS values remain', () => {
const result = normalizeOSList(['FreeBSD', 'Android', 'iOS'])
expect(result).toBeUndefined()
})
it('should handle mixed valid and invalid values', () => {
const result = normalizeOSList([
'windows',
'Windows',
'linux',
'Linux',
'macos'
])
// Only exact matches are valid
expect(result).toEqual(['Windows', 'Linux'])
})
it('should preserve order of first occurrence when deduplicating', () => {
const result = normalizeOSList([
'Linux',
'Windows',
'macOS',
'Linux',
'Windows'
])
expect(result).toEqual(['Linux', 'Windows', 'macOS'])
})
})
})

View File

@@ -0,0 +1,344 @@
import { describe, expect, it, vi } from 'vitest'
import {
checkVersionCompatibility,
getFrontendVersion
} from '@/workbench/extensions/manager/utils/versionUtil'
// Mock config module
vi.mock('@/config', () => ({
default: {
app_version: '1.24.0-1'
}
}))
describe('versionUtil', () => {
describe('checkVersionCompatibility', () => {
it('should return null when current version is undefined', () => {
const result = checkVersionCompatibility(
'comfyui_version',
undefined,
'>=1.0.0'
)
expect(result).toBeNull()
})
it('should return null when current version is null', () => {
const result = checkVersionCompatibility(
'comfyui_version',
null as any,
'>=1.0.0'
)
expect(result).toBeNull()
})
it('should return null when current version is empty string', () => {
const result = checkVersionCompatibility('comfyui_version', '', '>=1.0.0')
expect(result).toBeNull()
})
it('should return null when supported version is undefined', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'1.0.0',
undefined
)
expect(result).toBeNull()
})
it('should return null when supported version is null', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'1.0.0',
null as any
)
expect(result).toBeNull()
})
it('should return null when supported version is empty string', () => {
const result = checkVersionCompatibility('comfyui_version', '1.0.0', '')
expect(result).toBeNull()
})
it('should return null when supported version is whitespace only', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'1.0.0',
' '
)
expect(result).toBeNull()
})
describe('version compatibility checks', () => {
it('should return null when version satisfies >= requirement', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'2.0.0',
'>=1.0.0'
)
expect(result).toBeNull()
})
it('should return null when version exactly matches requirement', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'1.0.0',
'1.0.0'
)
expect(result).toBeNull()
})
it('should return null when version satisfies ^ requirement', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'1.2.3',
'^1.0.0'
)
expect(result).toBeNull()
})
it('should return null when version satisfies ~ requirement', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'1.0.5',
'~1.0.0'
)
expect(result).toBeNull()
})
it('should return null when version satisfies range requirement', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'1.5.0',
'1.0.0 - 2.0.0'
)
expect(result).toBeNull()
})
it('should return conflict when version does not satisfy >= requirement', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'0.9.0',
'>=1.0.0'
)
expect(result).toEqual({
type: 'comfyui_version',
current_value: '0.9.0',
required_value: '>=1.0.0'
})
})
it('should return conflict when version does not satisfy ^ requirement', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'2.0.0',
'^1.0.0'
)
expect(result).toEqual({
type: 'comfyui_version',
current_value: '2.0.0',
required_value: '^1.0.0'
})
})
it('should return conflict when version is outside range', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'3.0.0',
'1.0.0 - 2.0.0'
)
expect(result).toEqual({
type: 'comfyui_version',
current_value: '3.0.0',
required_value: '1.0.0 - 2.0.0'
})
})
})
describe('version cleaning', () => {
it('should handle versions with v prefix', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'v1.0.0',
'>=1.0.0'
)
expect(result).toBeNull()
})
it('should handle versions with pre-release tags', () => {
// Pre-release versions have specific semver rules
// 1.0.0-alpha satisfies >=1.0.0-alpha but not >=1.0.0
const result = checkVersionCompatibility(
'comfyui_version',
'1.0.0-alpha',
'>=1.0.0-alpha'
)
expect(result).toBeNull()
// This should fail because pre-release < stable
const result2 = checkVersionCompatibility(
'comfyui_version',
'1.0.0-alpha',
'>=1.0.0'
)
expect(result2).toEqual({
type: 'comfyui_version',
current_value: '1.0.0-alpha',
required_value: '>=1.0.0'
})
})
it('should handle versions with build metadata', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'1.0.0+build123',
'>=1.0.0'
)
expect(result).toBeNull()
})
it('should handle malformed versions gracefully', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'not-a-version',
'>=1.0.0'
)
expect(result).toEqual({
type: 'comfyui_version',
current_value: 'not-a-version',
required_value: '>=1.0.0'
})
})
})
describe('different conflict types', () => {
it('should handle comfyui_version type', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'0.5.0',
'>=1.0.0'
)
expect(result?.type).toBe('comfyui_version')
})
it('should handle frontend_version type', () => {
const result = checkVersionCompatibility(
'frontend_version',
'0.5.0',
'>=1.0.0'
)
expect(result?.type).toBe('frontend_version')
})
})
describe('complex version ranges', () => {
it('should handle OR conditions with ||', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'1.5.0',
'>=1.0.0 <2.0.0 || >=3.0.0'
)
expect(result).toBeNull()
})
it('should handle multiple constraints', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'1.5.0',
'>=1.0.0 <2.0.0'
)
expect(result).toBeNull()
})
it('should return conflict when no constraints are met', () => {
const result = checkVersionCompatibility(
'comfyui_version',
'2.5.0',
'>=1.0.0 <2.0.0 || >=3.0.0 <4.0.0'
)
expect(result).toEqual({
type: 'comfyui_version',
current_value: '2.5.0',
required_value: '>=1.0.0 <2.0.0 || >=3.0.0 <4.0.0'
})
})
})
})
describe('getFrontendVersion', () => {
it('should return app_version from config when available', () => {
const version = getFrontendVersion()
expect(version).toBe('1.24.0-1')
})
it('should fallback to VITE_APP_VERSION when app_version is not available', async () => {
// Save original environment
const originalEnv = import.meta.env.VITE_APP_VERSION
// Mock config without app_version
vi.doMock('@/config', () => ({
default: {}
}))
// Set VITE_APP_VERSION
import.meta.env.VITE_APP_VERSION = '2.0.0'
// Clear module cache to force re-import
vi.resetModules()
// Import fresh module
const versionUtil =
await import('@/workbench/extensions/manager/utils/versionUtil')
const version = versionUtil.getFrontendVersion()
expect(version).toBe('2.0.0')
// Restore original env
import.meta.env.VITE_APP_VERSION = originalEnv
// Reset mocks for next test
vi.resetModules()
vi.doMock('@/config', () => ({
default: {
app_version: '1.24.0-1'
}
}))
})
it('should return undefined when no version is available', async () => {
// Save original environment
const originalEnv = import.meta.env.VITE_APP_VERSION
// Mock config without app_version
vi.doMock('@/config', () => ({
default: {}
}))
// Clear VITE_APP_VERSION
delete import.meta.env.VITE_APP_VERSION
// Clear module cache to force re-import
vi.resetModules()
// Import fresh module
const versionUtil =
await import('@/workbench/extensions/manager/utils/versionUtil')
const version = versionUtil.getFrontendVersion()
expect(version).toBeUndefined()
// Restore original env
if (originalEnv !== undefined) {
import.meta.env.VITE_APP_VERSION = originalEnv
}
// Reset mocks for next test
vi.resetModules()
vi.doMock('@/config', () => ({
default: {
app_version: '1.24.0-1'
}
}))
})
})
})