merge main into rh-test

This commit is contained in:
bymyself
2025-09-28 15:33:29 -07:00
parent 1c0f151d02
commit ff0c15b119
1317 changed files with 85439 additions and 18373 deletions

View File

@@ -0,0 +1,30 @@
import axios from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
vi.mock('axios')
describe('getFolderPaths', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('returns legacy API response when available', async () => {
const mockResponse = { checkpoints: ['/test/checkpoints'] }
vi.mocked(axios.get).mockResolvedValueOnce({ data: mockResponse })
const result = await api.getFolderPaths()
expect(result).toEqual(mockResponse)
})
it('returns empty object when legacy API unavailable (dynamic discovery)', async () => {
vi.mocked(axios.get).mockRejectedValueOnce(new Error())
const result = await api.getFolderPaths()
// With dynamic discovery, we don't pre-generate directories when API is unavailable
expect(result).toEqual({})
})
})

View File

@@ -28,7 +28,7 @@ vi.mock('@/scripts/api', () => ({
api: mockApi
}))
vi.mock('@/stores/toastStore', () => ({
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => mockToastStore)
}))

View File

@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { downloadFile } from '@/base/common/downloadUtil'
describe('downloadUtil', () => {
let mockLink: HTMLAnchorElement
beforeEach(() => {
// Create a mock anchor element
mockLink = {
href: '',
download: '',
click: vi.fn()
} as unknown as HTMLAnchorElement
// Spy on DOM methods
vi.spyOn(document, 'createElement').mockReturnValue(mockLink)
vi.spyOn(document.body, 'appendChild').mockImplementation(() => mockLink)
vi.spyOn(document.body, 'removeChild').mockImplementation(() => mockLink)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('downloadFile', () => {
it('should create and trigger download with basic URL', () => {
const testUrl = 'https://example.com/image.png'
downloadFile(testUrl)
expect(document.createElement).toHaveBeenCalledWith('a')
expect(mockLink.href).toBe(testUrl)
expect(mockLink.download).toBe('download.png') // Default filename
expect(document.body.appendChild).toHaveBeenCalledWith(mockLink)
expect(mockLink.click).toHaveBeenCalled()
expect(document.body.removeChild).toHaveBeenCalledWith(mockLink)
})
it('should use custom filename when provided', () => {
const testUrl = 'https://example.com/image.png'
const customFilename = 'my-custom-image.png'
downloadFile(testUrl, customFilename)
expect(mockLink.href).toBe(testUrl)
expect(mockLink.download).toBe(customFilename)
})
it('should extract filename from URL query parameters', () => {
const testUrl =
'https://example.com/api/file?filename=extracted-image.jpg&other=param'
downloadFile(testUrl)
expect(mockLink.href).toBe(testUrl)
expect(mockLink.download).toBe('extracted-image.jpg')
})
it('should use default filename when URL has no filename parameter', () => {
const testUrl = 'https://example.com/api/file?other=param'
downloadFile(testUrl)
expect(mockLink.href).toBe(testUrl)
expect(mockLink.download).toBe('download.png')
})
it('should handle invalid URLs gracefully', () => {
const invalidUrl = 'not-a-valid-url'
downloadFile(invalidUrl)
expect(mockLink.href).toBe(invalidUrl)
expect(mockLink.download).toBe('download.png')
expect(mockLink.click).toHaveBeenCalled()
})
it('should prefer custom filename over extracted filename', () => {
const testUrl =
'https://example.com/api/file?filename=extracted-image.jpg'
const customFilename = 'custom-override.png'
downloadFile(testUrl, customFilename)
expect(mockLink.download).toBe(customFilename)
})
it('should handle URLs with empty filename parameter', () => {
const testUrl = 'https://example.com/api/file?filename='
downloadFile(testUrl)
expect(mockLink.download).toBe('download.png')
})
it('should handle relative URLs by using window.location.origin', () => {
const relativeUrl = '/api/file?filename=relative-image.png'
downloadFile(relativeUrl)
expect(mockLink.href).toBe(relativeUrl)
expect(mockLink.download).toBe('relative-image.png')
})
it('should clean up DOM elements after download', () => {
const testUrl = 'https://example.com/image.png'
downloadFile(testUrl)
// Verify the element was added and then removed
expect(document.body.appendChild).toHaveBeenCalledWith(mockLink)
expect(document.body.removeChild).toHaveBeenCalledWith(mockLink)
})
})
})

View File

@@ -1,6 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import { adjustColor } from '@/utils/colorUtil'
import {
adjustColor,
hexToRgb,
hsbToRgb,
parseToRgb,
rgbToHex
} from '@/utils/colorUtil'
interface ColorTestCase {
hex: string
@@ -55,6 +61,74 @@ const colors: Record<string, ColorTestCase> = {
const formats: ColorFormat[] = ['hex', 'rgb', 'rgba', 'hsl', 'hsla']
describe('colorUtil conversions', () => {
describe('hexToRgb / rgbToHex', () => {
it('converts 6-digit hex to RGB', () => {
expect(hexToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })
expect(hexToRgb('#00ff00')).toEqual({ r: 0, g: 255, b: 0 })
expect(hexToRgb('#0000ff')).toEqual({ r: 0, g: 0, b: 255 })
})
it('converts 3-digit hex to RGB', () => {
expect(hexToRgb('#f00')).toEqual({ r: 255, g: 0, b: 0 })
expect(hexToRgb('#0f0')).toEqual({ r: 0, g: 255, b: 0 })
expect(hexToRgb('#00f')).toEqual({ r: 0, g: 0, b: 255 })
})
it('converts RGB to lowercase #hex and clamps values', () => {
expect(rgbToHex({ r: 255, g: 0, b: 0 })).toBe('#ff0000')
expect(rgbToHex({ r: 0, g: 255, b: 0 })).toBe('#00ff00')
expect(rgbToHex({ r: 0, g: 0, b: 255 })).toBe('#0000ff')
// out-of-range should clamp
expect(rgbToHex({ r: -10, g: 300, b: 16 })).toBe('#00ff10')
})
it('round-trips #hex -> rgb -> #hex', () => {
const hex = '#123abc'
expect(rgbToHex(hexToRgb(hex))).toBe('#123abc')
})
})
describe('parseToRgb', () => {
it('parses #hex', () => {
expect(parseToRgb('#ff0000')).toEqual({ r: 255, g: 0, b: 0 })
})
it('parses rgb()/rgba()', () => {
expect(parseToRgb('rgb(255, 0, 0)')).toEqual({ r: 255, g: 0, b: 0 })
expect(parseToRgb('rgba(255,0,0,0.5)')).toEqual({ r: 255, g: 0, b: 0 })
})
it('parses hsl()/hsla()', () => {
expect(parseToRgb('hsl(0, 100%, 50%)')).toEqual({ r: 255, g: 0, b: 0 })
const green = parseToRgb('hsla(120, 100%, 50%, 0.7)')
expect(green.r).toBe(0)
expect(green.g).toBe(255)
expect(green.b).toBe(0)
})
})
describe('hsbToRgb', () => {
it('converts HSB to primary RGB colors', () => {
expect(hsbToRgb({ h: 0, s: 100, b: 100 })).toEqual({ r: 255, g: 0, b: 0 })
expect(hsbToRgb({ h: 120, s: 100, b: 100 })).toEqual({
r: 0,
g: 255,
b: 0
})
expect(hsbToRgb({ h: 240, s: 100, b: 100 })).toEqual({
r: 0,
g: 0,
b: 255
})
})
it('handles non-100 brightness and clamps/normalizes input', () => {
const rgb = hsbToRgb({ h: 360, s: 150, b: 50 })
expect(rgbToHex(rgb)).toBe('#7f0000')
})
})
})
describe('colorUtil - adjustColor', () => {
const runAdjustColorTests = (
color: ColorTestCase,

View File

@@ -1,7 +1,7 @@
import fs from 'fs'
import { describe, expect, it } from 'vitest'
import { validateComfyWorkflow } from '@/schemas/comfyWorkflowSchema'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { defaultGraph } from '@/scripts/defaultGraph'
const WORKFLOW_DIR = 'tests-ui/workflows'

View File

@@ -0,0 +1,219 @@
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
// Mock xterm and related modules
vi.mock('@xterm/xterm', () => ({
Terminal: vi.fn().mockImplementation(() => ({
open: vi.fn(),
dispose: vi.fn(),
onSelectionChange: vi.fn(() => {
// Return a disposable
return {
dispose: vi.fn()
}
}),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
selectAll: vi.fn(),
clearSelection: vi.fn(),
loadAddon: vi.fn()
})),
IDisposable: vi.fn()
}))
vi.mock('@xterm/addon-fit', () => ({
FitAddon: vi.fn().mockImplementation(() => ({
fit: vi.fn(),
proposeDimensions: vi.fn(() => ({ rows: 24, cols: 80 }))
}))
}))
const mockTerminal = {
open: vi.fn(),
dispose: vi.fn(),
onSelectionChange: vi.fn(() => ({
dispose: vi.fn()
})),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
selectAll: vi.fn(),
clearSelection: vi.fn()
}
vi.mock('@/composables/bottomPanelTabs/useTerminal', () => ({
useTerminal: vi.fn(() => ({
terminal: mockTerminal,
useAutoSize: vi.fn(() => ({ resize: vi.fn() }))
}))
}))
vi.mock('@/utils/envUtil', () => ({
isElectron: vi.fn(() => false),
electronAPI: vi.fn(() => null)
}))
// Mock clipboard API
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: vi.fn().mockResolvedValue(undefined)
},
configurable: true
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
serverStart: {
copySelectionTooltip: 'Copy selection',
copyAllTooltip: 'Copy all'
}
}
}
})
const mountBaseTerminal = () => {
return mount(BaseTerminal, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
Button: {
template: '<button v-bind="$attrs"><slot /></button>',
props: ['icon', 'severity', 'size']
}
}
}
})
}
describe('BaseTerminal', () => {
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
wrapper?.unmount()
})
it('emits created event on mount', () => {
wrapper = mountBaseTerminal()
expect(wrapper.emitted('created')).toBeTruthy()
expect(wrapper.emitted('created')![0]).toHaveLength(2)
})
it('emits unmounted event on unmount', () => {
wrapper = mountBaseTerminal()
wrapper.unmount()
expect(wrapper.emitted('unmounted')).toBeTruthy()
})
it('button exists and has correct initial state', async () => {
wrapper = mountBaseTerminal()
const button = wrapper.find('button[aria-label]')
expect(button.exists()).toBe(true)
expect(button.classes()).toContain('opacity-0')
expect(button.classes()).toContain('pointer-events-none')
})
it('shows correct tooltip when no selection', async () => {
mockTerminal.hasSelection.mockReturnValue(false)
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy all')
})
it('shows correct tooltip when selection exists', async () => {
mockTerminal.hasSelection.mockReturnValue(true)
wrapper = mountBaseTerminal()
// Trigger the selection change callback that was registered during mount
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
// Access the mock calls - TypeScript can't infer the mock structure dynamically
const selectionCallback = (mockTerminal.onSelectionChange as any).mock
.calls[0][0]
selectionCallback()
await nextTick()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
expect(button.attributes('aria-label')).toBe('Copy selection')
})
it('copies selected text when selection exists', async () => {
const selectedText = 'selected text'
mockTerminal.hasSelection.mockReturnValue(true)
mockTerminal.getSelection.mockReturnValue(selectedText)
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).not.toHaveBeenCalled()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(selectedText)
expect(mockTerminal.clearSelection).not.toHaveBeenCalled()
})
it('copies all text when no selection exists', async () => {
const allText = 'all terminal content'
mockTerminal.hasSelection.mockReturnValue(false)
mockTerminal.getSelection
.mockReturnValueOnce('') // First call returns empty (no selection)
.mockReturnValueOnce(allText) // Second call after selectAll returns all text
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(allText)
expect(mockTerminal.clearSelection).toHaveBeenCalled()
})
it('does not copy when no text available', async () => {
mockTerminal.hasSelection.mockReturnValue(false)
mockTerminal.getSelection.mockReturnValue('')
wrapper = mountBaseTerminal()
await wrapper.trigger('mouseenter')
await nextTick()
const button = wrapper.find('button[aria-label]')
await button.trigger('click')
expect(mockTerminal.selectAll).toHaveBeenCalled()
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
})
})

View File

@@ -33,14 +33,14 @@ const createMockNode = (type: string, version?: string): LGraphNode =>
describe('MissingCoreNodesMessage', () => {
const mockSystemStatsStore = {
systemStats: null as { system?: { comfyui_version?: string } } | null,
fetchSystemStats: vi.fn()
refetchSystemStats: vi.fn()
}
beforeEach(() => {
vi.clearAllMocks()
// Reset the mock store state
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.fetchSystemStats = vi.fn()
mockSystemStatsStore.refetchSystemStats = vi.fn()
// @ts-expect-error - Mocking the return value of useSystemStatsStore for testing.
// The actual store has more properties, but we only need these for our tests.
useSystemStatsStore.mockReturnValue(mockSystemStatsStore)
@@ -86,15 +86,11 @@ describe('MissingCoreNodesMessage', () => {
expect(wrapper.findComponent(Message).exists()).toBe(true)
})
it('fetches and displays current ComfyUI version', async () => {
// Start with no systemStats to trigger fetch
mockSystemStatsStore.fetchSystemStats.mockImplementation(() => {
// Simulate the fetch setting the systemStats
mockSystemStatsStore.systemStats = {
system: { comfyui_version: '1.0.0' }
}
return Promise.resolve()
})
it('displays current ComfyUI version when available', async () => {
// Set systemStats directly (store auto-fetches with useAsyncState)
mockSystemStatsStore.systemStats = {
system: { comfyui_version: '1.0.0' }
}
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]
@@ -102,20 +98,18 @@ describe('MissingCoreNodesMessage', () => {
const wrapper = mountComponent({ missingCoreNodes })
// Wait for all async operations
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
// Wait for component to render
await nextTick()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
// No need to check if fetchSystemStats was called since useAsyncState auto-fetches
expect(wrapper.text()).toContain(
'Some nodes require a newer version of ComfyUI (current: 1.0.0)'
)
})
it('displays generic message when version is unavailable', async () => {
// Mock fetchSystemStats to resolve without setting systemStats
mockSystemStatsStore.fetchSystemStats.mockResolvedValue(undefined)
// No systemStats set - version unavailable
mockSystemStatsStore.systemStats = null
const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')]

View File

@@ -0,0 +1,458 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Button from 'primevue/button'
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()
expect(wrapper.text()).toContain('0')
expect(wrapper.text()).toContain('Conflicts')
expect(wrapper.text()).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-neutral-200'
)[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-neutral-200'
)[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('.w-full.h-8.flex.items-center')
// Initially collapsed
expect(
wrapper.find('[class*="py-2 px-4 flex flex-col gap-2.5"]').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(
'[class*="py-2 px-4 flex flex-col gap-2.5"]'
)
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.props('icon')).toContain('pi-chevron-down')
})
it('should toggle conflicts panel', async () => {
const wrapper = createWrapper()
// Find conflicts panel header (second one)
const conflictsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[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(
'.w-full.h-8.flex.items-center'
)[2]
// Click to expand extensions panel
await extensionsHeader.trigger('click')
// Should be expanded now and show all package names
const expandedContent = wrapper.findAll(
'[class*="py-2 px-4 flex flex-col gap-2.5"]'
)[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(
'.w-full.h-8.flex.items-center'
)[0]
const conflictsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[1]
const extensionsHeader = wrapper.findAll(
'.w-full.h-8.flex.items-center'
)[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(
'.w-full.h-8.flex.items-center'
)[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(
'.w-full.h-8.flex.items-center'
)[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(
'.w-full.h-8.flex.items-center'
)[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()
expect(wrapper.text()).toContain('0')
expect(wrapper.text()).toContain('Conflicts')
expect(wrapper.text()).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('.w-full.h-8.flex.items-center')
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.props('icon')).toBeDefined()
expect(button.props('icon')).toMatch(/pi-chevron-(right|down)/)
})
})
it('should have clickable panel headers', () => {
mockConflictData.value = mockConflictResults
const wrapper = createWrapper()
const headers = wrapper.findAll('.w-full.h-8.flex.items-center')
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,233 @@
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)
})
})
describe('styling', () => {
it('should have correct CSS classes', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
const card = wrapper.find('.p-card')
expect(card.classes()).toContain('w-full')
expect(card.classes()).toContain('h-full')
expect(card.classes()).toContain('rounded-lg')
})
it('should have correct base styling', () => {
const wrapper = createWrapper({ nodePack: mockNodePack })
const card = wrapper.find('.p-card')
// Check the actual classes applied to the card
expect(card.classes()).toContain('p-card')
expect(card.classes()).toContain('p-component')
expect(card.classes()).toContain('inline-flex')
expect(card.classes()).toContain('flex-col')
})
})
})

View File

@@ -0,0 +1,481 @@
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: {
progressCountOf: 'of'
},
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

@@ -28,14 +28,14 @@ vi.mock('@/stores/commandStore', () => ({
})
}))
vi.mock('@/stores/graphStore', () => ({
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
appScalePercentage: 100,
setAppZoomFromPercentage: mockSetAppZoom
})
}))
vi.mock('@/stores/settingStore', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: mockSettingGet
})

View File

@@ -0,0 +1,435 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import WhatsNewPopup from '@/platform/updates/components/WhatsNewPopup.vue'
import type { components } from '@/types/comfyRegistryTypes'
type ReleaseNote = components['schemas']['ReleaseNote']
// Mock dependencies
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: { value: 'en' },
t: vi.fn((key) => key)
}))
}))
vi.mock('marked', () => ({
marked: vi.fn((content) => `<p>${content}</p>`)
}))
vi.mock('@/platform/updates/common/releaseStore', () => ({
useReleaseStore: vi.fn()
}))
describe('WhatsNewPopup', () => {
const mockReleaseStore = {
recentRelease: null as ReleaseNote | null,
shouldShowPopup: false,
handleWhatsNewSeen: vi.fn(),
releases: [] as ReleaseNote[],
fetchReleases: vi.fn()
}
const createWrapper = (props = {}) => {
return mount(WhatsNewPopup, {
props,
global: {
mocks: {
$t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'g.close': 'Close',
'whatsNewPopup.noReleaseNotes': 'No release notes available'
}
return translations[key] || key
})
}
}
})
}
beforeEach(async () => {
vi.clearAllMocks()
// Reset mock store
mockReleaseStore.recentRelease = null
mockReleaseStore.shouldShowPopup = false
mockReleaseStore.releases = []
// Mock release store
const { useReleaseStore } = await import(
'@/platform/updates/common/releaseStore'
)
vi.mocked(useReleaseStore).mockReturnValue(mockReleaseStore as any)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('visibility', () => {
it('should not show when shouldShowPopup is false', () => {
mockReleaseStore.shouldShowPopup = false
const wrapper = createWrapper()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
})
it('should show when shouldShowPopup is true and not dismissed', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'New features added',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
expect(wrapper.find('.whats-new-popup').exists()).toBe(true)
})
it('should hide when dismissed locally', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'New features added',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
// Initially visible
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
// Click close button
await wrapper.find('.close-button').trigger('click')
// Should be hidden
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
})
})
describe('content rendering', () => {
it('should render release content using marked', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: '# Release Notes\n\nNew features',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
// Check that the content is rendered (marked is mocked to return processed content)
expect(wrapper.find('.content-text').exists()).toBe(true)
const contentHtml = wrapper.find('.content-text').html()
expect(contentHtml).toContain('<p># Release Notes')
})
it('should handle missing release content', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: '',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
expect(wrapper.find('.content-text').html()).toContain(
'whatsNewPopup.noReleaseNotes'
)
})
it('should handle markdown parsing errors gracefully', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Content with\nnewlines',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
// Should show content even without markdown processing
expect(wrapper.find('.content-text').exists()).toBe(true)
})
})
describe('changelog URL generation', () => {
it('should generate English changelog URL with version anchor', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0-beta.1',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
const learnMoreLink = wrapper.find('.learn-more-link')
// formatVersionAnchor replaces dots with dashes: 1.24.0-beta.1 -> v1-24-0-beta-1
expect(learnMoreLink.attributes('href')).toBe(
'https://docs.comfy.org/changelog#v1-24-0-beta-1'
)
})
it('should generate Chinese changelog URL when locale is zh', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper({
global: {
mocks: {
$t: vi.fn((key: string) => {
const translations: Record<string, string> = {
'g.close': 'Close',
'whatsNewPopup.noReleaseNotes': 'No release notes available',
'whatsNewPopup.learnMore': 'Learn More'
}
return translations[key] || key
})
},
provide: {
// Mock vue-i18n locale as Chinese
locale: { value: 'zh' }
}
}
})
// Since the locale mocking doesn't work well in tests, just check the English URL for now
// In a real component test with proper i18n setup, this would show the Chinese URL
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('href')).toBe(
'https://docs.comfy.org/changelog#v1-24-0'
)
})
it('should generate base changelog URL when no version available', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('href')).toBe(
'https://docs.comfy.org/changelog'
)
})
})
describe('popup dismissal', () => {
it('should call handleWhatsNewSeen and emit event when closed', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
mockReleaseStore.handleWhatsNewSeen.mockResolvedValue(undefined)
const wrapper = createWrapper()
// Click close button
await wrapper.find('.close-button').trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.24.0')
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
expect(wrapper.emitted('whats-new-dismissed')).toHaveLength(1)
})
it('should close when learn more link is clicked', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Release content',
published_at: '2023-01-01T00:00:00Z'
}
mockReleaseStore.handleWhatsNewSeen.mockResolvedValue(undefined)
const wrapper = createWrapper()
// Click learn more link
await wrapper.find('.learn-more-link').trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.24.0')
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
})
it('should handle cases where no release is available during close', async () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = null
const wrapper = createWrapper()
// Try to close
await wrapper.find('.close-button').trigger('click')
expect(mockReleaseStore.handleWhatsNewSeen).not.toHaveBeenCalled()
expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
})
})
describe('exposed methods', () => {
it('should expose show and hide methods', () => {
const wrapper = createWrapper()
expect(wrapper.vm.show).toBeDefined()
expect(wrapper.vm.hide).toBeDefined()
expect(typeof wrapper.vm.show).toBe('function')
expect(typeof wrapper.vm.hide).toBe('function')
})
it('should show popup when show method is called', async () => {
mockReleaseStore.shouldShowPopup = true
const wrapper = createWrapper()
// Initially hide it
wrapper.vm.hide()
await nextTick()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
// Show it
wrapper.vm.show()
await nextTick()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
})
it('should hide popup when hide method is called', async () => {
mockReleaseStore.shouldShowPopup = true
const wrapper = createWrapper()
// Initially visible
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(true)
// Hide it
wrapper.vm.hide()
await nextTick()
expect(wrapper.find('.whats-new-popup-container').exists()).toBe(false)
})
})
describe('initialization', () => {
it('should fetch releases on mount if not already loaded', async () => {
mockReleaseStore.releases = []
mockReleaseStore.fetchReleases.mockResolvedValue(undefined)
createWrapper()
// Wait for onMounted
await nextTick()
expect(mockReleaseStore.fetchReleases).toHaveBeenCalled()
})
it('should not fetch releases if already loaded', async () => {
mockReleaseStore.releases = [
{
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium' as const,
content: 'Content',
published_at: '2023-01-01T00:00:00Z'
}
]
mockReleaseStore.fetchReleases.mockResolvedValue(undefined)
createWrapper()
// Wait for onMounted
await nextTick()
expect(mockReleaseStore.fetchReleases).not.toHaveBeenCalled()
})
})
describe('accessibility', () => {
it('should have proper aria-label for close button', () => {
const mockT = vi.fn((key) => (key === 'g.close' ? 'Close' : key))
vi.doMock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
locale: { value: 'en' },
t: mockT
}))
}))
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
expect(wrapper.find('.close-button').attributes('aria-label')).toBe(
'Close'
)
})
it('should have proper link attributes for external changelog', () => {
mockReleaseStore.shouldShowPopup = true
mockReleaseStore.recentRelease = {
id: 1,
project: 'comfyui_frontend',
version: '1.24.0',
attention: 'medium',
content: 'Content',
published_at: '2023-01-01T00:00:00Z'
}
const wrapper = createWrapper()
const learnMoreLink = wrapper.find('.learn-more-link')
expect(learnMoreLink.attributes('target')).toBe('_blank')
expect(learnMoreLink.attributes('rel')).toBe('noopener,noreferrer')
})
})
})

View File

@@ -26,7 +26,7 @@ vi.mock('@/stores/executionStore', () => ({
const settingStore = reactive({
get: vi.fn(() => 'Enabled')
})
vi.mock('@/stores/settingStore', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => settingStore
}))
@@ -34,7 +34,7 @@ vi.mock('@/stores/settingStore', () => ({
const workflowStore = reactive({
activeWorkflow: null as any
})
vi.mock('@/stores/workflowStore', () => ({
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => workflowStore
}))

View File

@@ -1,129 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
// Mock canvas store
let mockGetCanvas = vi.fn()
vi.mock('@/stores/graphStore', () => ({
useCanvasStore: vi.fn(() => ({
getCanvas: mockGetCanvas
}))
}))
describe('useCanvasTransformSync', () => {
let mockCanvas: { ds: { scale: number; offset: [number, number] } }
let syncFn: ReturnType<typeof vi.fn>
beforeEach(() => {
mockCanvas = {
ds: {
scale: 1,
offset: [0, 0]
}
}
syncFn = vi.fn()
mockGetCanvas = vi.fn(() => mockCanvas)
vi.clearAllMocks()
})
it('should not call syncFn when transform has not changed', async () => {
const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false })
startSync()
await nextTick()
// Should call once initially
expect(syncFn).toHaveBeenCalledTimes(1)
// Wait for next RAF cycle
await new Promise((resolve) => requestAnimationFrame(resolve))
// Should not call again since transform didn't change
expect(syncFn).toHaveBeenCalledTimes(1)
})
it('should call syncFn when scale changes', async () => {
const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false })
startSync()
await nextTick()
expect(syncFn).toHaveBeenCalledTimes(1)
// Change scale
mockCanvas.ds.scale = 2
// Wait for next RAF cycle
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(syncFn).toHaveBeenCalledTimes(2)
})
it('should call syncFn when offset changes', async () => {
const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false })
startSync()
await nextTick()
expect(syncFn).toHaveBeenCalledTimes(1)
// Change offset
mockCanvas.ds.offset = [10, 20]
// Wait for next RAF cycles
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(syncFn).toHaveBeenCalledTimes(2)
})
it('should stop calling syncFn after stopSync is called', async () => {
const { startSync, stopSync } = useCanvasTransformSync(syncFn, {
autoStart: false
})
startSync()
await nextTick()
expect(syncFn).toHaveBeenCalledTimes(1)
stopSync()
// Change transform after stopping
mockCanvas.ds.scale = 2
// Wait for RAF cycle
await new Promise((resolve) => requestAnimationFrame(resolve))
// Should not call again
expect(syncFn).toHaveBeenCalledTimes(1)
})
it('should handle null canvas gracefully', async () => {
mockGetCanvas.mockReturnValue(null)
const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false })
startSync()
await nextTick()
// Should not call syncFn with null canvas
expect(syncFn).not.toHaveBeenCalled()
})
it('should call onStart and onStop callbacks', () => {
const onStart = vi.fn()
const onStop = vi.fn()
const { startSync, stopSync } = useCanvasTransformSync(syncFn, {
autoStart: false,
onStart,
onStop
})
startSync()
expect(onStart).toHaveBeenCalledTimes(1)
stopSync()
expect(onStop).toHaveBeenCalledTimes(1)
})
})

View File

@@ -2,14 +2,14 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
LGraphEventMode,
LGraphNode,
Positionable,
type Positionable,
Reroute
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
// Mock the app module
vi.mock('@/scripts/app', () => ({
@@ -237,9 +237,9 @@ describe('useSelectedLiteGraphItems', () => {
toggleSelectedNodesMode(LGraphEventMode.NEVER)
// node1 should change from ALWAYS to NEVER
// node2 should change from NEVER to ALWAYS (since it was already NEVER)
// node2 should stay NEVER (since a selected node exists which is not NEVER)
expect(node1.mode).toBe(LGraphEventMode.NEVER)
expect(node2.mode).toBe(LGraphEventMode.ALWAYS)
expect(node2.mode).toBe(LGraphEventMode.NEVER)
})
it('toggleSelectedNodesMode should set mode to ALWAYS when already in target mode', () => {

View File

@@ -0,0 +1,351 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
// Create a mock canvas context for transform testing
function createMockCanvasContext() {
return {
canvas: {
width: 1280,
height: 720,
getBoundingClientRect: () => ({
left: 0,
top: 0,
width: 1280,
height: 720,
right: 1280,
bottom: 720,
x: 0,
y: 0
})
},
ds: {
offset: [0, 0],
scale: 1
}
}
}
describe('useTransformState', () => {
let transformState: ReturnType<typeof useTransformState>
beforeEach(() => {
transformState = useTransformState()
})
describe('initial state', () => {
it('should initialize with default camera values', () => {
const { camera } = transformState
expect(camera.x).toBe(0)
expect(camera.y).toBe(0)
expect(camera.z).toBe(1)
})
it('should generate correct initial transform style', () => {
const { transformStyle } = transformState
expect(transformStyle.value).toEqual({
transform: 'scale(1) translate(0px, 0px)',
transformOrigin: '0 0'
})
})
})
describe('syncWithCanvas', () => {
it('should sync camera state with canvas transform', () => {
const { syncWithCanvas, camera } = transformState
const mockCanvas = createMockCanvasContext()
// Set mock canvas transform
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
syncWithCanvas(mockCanvas as any)
expect(camera.x).toBe(100)
expect(camera.y).toBe(50)
expect(camera.z).toBe(2)
})
it('should handle null canvas gracefully', () => {
const { syncWithCanvas, camera } = transformState
syncWithCanvas(null as any)
// Should remain at initial values
expect(camera.x).toBe(0)
expect(camera.y).toBe(0)
expect(camera.z).toBe(1)
})
it('should handle canvas without ds property', () => {
const { syncWithCanvas, camera } = transformState
const canvasWithoutDs = { canvas: {} }
syncWithCanvas(canvasWithoutDs as any)
// Should remain at initial values
expect(camera.x).toBe(0)
expect(camera.y).toBe(0)
expect(camera.z).toBe(1)
})
it('should update transform style after sync', () => {
const { syncWithCanvas, transformStyle } = transformState
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [150, 75]
mockCanvas.ds.scale = 0.5
syncWithCanvas(mockCanvas as any)
expect(transformStyle.value).toEqual({
transform: 'scale(0.5) translate(150px, 75px)',
transformOrigin: '0 0'
})
})
})
describe('coordinate conversions', () => {
beforeEach(() => {
// Set up a known transform state
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as any)
})
describe('canvasToScreen', () => {
it('should convert canvas coordinates to screen coordinates', () => {
const { canvasToScreen } = transformState
const canvasPoint = { x: 10, y: 20 }
const screenPoint = canvasToScreen(canvasPoint)
// screen = (canvas + offset) * scale
// x: (10 + 100) * 2 = 220
// y: (20 + 50) * 2 = 140
expect(screenPoint).toEqual({ x: 220, y: 140 })
})
it('should handle zero coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: 0, y: 0 })
expect(screenPoint).toEqual({ x: 200, y: 100 })
})
it('should handle negative coordinates', () => {
const { canvasToScreen } = transformState
const screenPoint = canvasToScreen({ x: -10, y: -20 })
expect(screenPoint).toEqual({ x: 180, y: 60 })
})
})
describe('screenToCanvas', () => {
it('should convert screen coordinates to canvas coordinates', () => {
const { screenToCanvas } = transformState
const screenPoint = { x: 220, y: 140 }
const canvasPoint = screenToCanvas(screenPoint)
// canvas = screen / scale - offset
// x: 220 / 2 - 100 = 10
// y: 140 / 2 - 50 = 20
expect(canvasPoint).toEqual({ x: 10, y: 20 })
})
it('should be inverse of canvasToScreen', () => {
const { canvasToScreen, screenToCanvas } = transformState
const originalPoint = { x: 25, y: 35 }
const screenPoint = canvasToScreen(originalPoint)
const backToCanvas = screenToCanvas(screenPoint)
expect(backToCanvas.x).toBeCloseTo(originalPoint.x)
expect(backToCanvas.y).toBeCloseTo(originalPoint.y)
})
})
})
describe('getNodeScreenBounds', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as any)
})
it('should calculate correct screen bounds for a node', () => {
const { getNodeScreenBounds } = transformState
const nodePos = [10, 20]
const nodeSize = [200, 100]
const bounds = getNodeScreenBounds(nodePos, nodeSize)
// Top-left: canvasToScreen(10, 20) = (220, 140)
// Width: 200 * 2 = 400
// Height: 100 * 2 = 200
expect(bounds.x).toBe(220)
expect(bounds.y).toBe(140)
expect(bounds.width).toBe(400)
expect(bounds.height).toBe(200)
})
})
describe('isNodeInViewport', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [0, 0]
mockCanvas.ds.scale = 1
transformState.syncWithCanvas(mockCanvas as any)
})
const viewport = { width: 1000, height: 600 }
it('should return true for nodes inside viewport', () => {
const { isNodeInViewport } = transformState
const nodePos = [100, 100]
const nodeSize = [200, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
})
it('should return false for nodes completely outside viewport', () => {
const { isNodeInViewport } = transformState
// Node far to the right
expect(isNodeInViewport([2000, 100], [200, 100], viewport)).toBe(false)
// Node far to the left
expect(isNodeInViewport([-500, 100], [200, 100], viewport)).toBe(false)
// Node far below
expect(isNodeInViewport([100, 1000], [200, 100], viewport)).toBe(false)
// Node far above
expect(isNodeInViewport([100, -500], [200, 100], viewport)).toBe(false)
})
it('should return true for nodes partially in viewport with margin', () => {
const { isNodeInViewport } = transformState
// Node slightly outside but within margin
const nodePos = [-50, -50]
const nodeSize = [100, 100]
expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
})
it('should return false for tiny nodes (size culling)', () => {
const { isNodeInViewport } = transformState
// Node is in viewport but too small
const nodePos = [100, 100]
const nodeSize = [3, 3] // Less than 4 pixels
expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
})
it('should adjust margin based on zoom level', () => {
const { isNodeInViewport, syncWithCanvas } = transformState
const mockCanvas = createMockCanvasContext()
// Test with very low zoom
mockCanvas.ds.scale = 0.05
syncWithCanvas(mockCanvas as any)
// Node at edge should still be visible due to increased margin
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(true)
// Test with high zoom
mockCanvas.ds.scale = 4
syncWithCanvas(mockCanvas as any)
// Margin should be tighter
expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(false)
})
})
describe('getViewportBounds', () => {
beforeEach(() => {
const mockCanvas = createMockCanvasContext()
mockCanvas.ds.offset = [100, 50]
mockCanvas.ds.scale = 2
transformState.syncWithCanvas(mockCanvas as any)
})
it('should calculate viewport bounds in canvas coordinates', () => {
const { getViewportBounds } = transformState
const viewport = { width: 1000, height: 600 }
const bounds = getViewportBounds(viewport, 0.2)
// With 20% margin:
// marginX = 1000 * 0.2 = 200
// marginY = 600 * 0.2 = 120
// topLeft in screen: (-200, -120)
// bottomRight in screen: (1200, 720)
// Convert to canvas coordinates (canvas = screen / scale - offset):
// topLeft: (-200 / 2 - 100, -120 / 2 - 50) = (-200, -110)
// bottomRight: (1200 / 2 - 100, 720 / 2 - 50) = (500, 310)
expect(bounds.x).toBe(-200)
expect(bounds.y).toBe(-110)
expect(bounds.width).toBe(700) // 500 - (-200)
expect(bounds.height).toBe(420) // 310 - (-110)
})
it('should handle zero margin', () => {
const { getViewportBounds } = transformState
const viewport = { width: 1000, height: 600 }
const bounds = getViewportBounds(viewport, 0)
// No margin, so viewport bounds are exact
expect(bounds.x).toBe(-100) // 0 / 2 - 100
expect(bounds.y).toBe(-50) // 0 / 2 - 50
expect(bounds.width).toBe(500) // 1000 / 2
expect(bounds.height).toBe(300) // 600 / 2
})
})
describe('edge cases', () => {
it('should handle extreme zoom levels', () => {
const { syncWithCanvas, canvasToScreen } = transformState
const mockCanvas = createMockCanvasContext()
// Very small zoom
mockCanvas.ds.scale = 0.001
syncWithCanvas(mockCanvas as any)
const point1 = canvasToScreen({ x: 1000, y: 1000 })
expect(point1.x).toBeCloseTo(1)
expect(point1.y).toBeCloseTo(1)
// Very large zoom
mockCanvas.ds.scale = 100
syncWithCanvas(mockCanvas as any)
const point2 = canvasToScreen({ x: 1, y: 1 })
expect(point2.x).toBe(100)
expect(point2.y).toBe(100)
})
it('should handle zero scale in screenToCanvas', () => {
const { syncWithCanvas, screenToCanvas } = transformState
const mockCanvas = createMockCanvasContext()
// Scale of 0 gets converted to 1 by || operator
mockCanvas.ds.scale = 0
syncWithCanvas(mockCanvas as any)
// Should use scale of 1 due to camera.z || 1 in implementation
const result = screenToCanvas({ x: 100, y: 100 })
expect(result.x).toBe(100) // (100 - 0) / 1
expect(result.y).toBe(100) // (100 - 0) / 1
})
})
})

View File

@@ -1,126 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasInteractions } from '@/composables/graph/useCanvasInteractions'
import { app } from '@/scripts/app'
import * as settingStore from '@/stores/settingStore'
// Mock the app and canvas
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
canvas: null as HTMLCanvasElement | null
}
}
}))
// Mock the setting store
vi.mock('@/stores/settingStore', () => ({
useSettingStore: vi.fn()
}))
describe('useCanvasInteractions', () => {
let mockCanvas: HTMLCanvasElement
let mockSettingStore: { get: ReturnType<typeof vi.fn> }
let canvasInteractions: ReturnType<typeof useCanvasInteractions>
beforeEach(() => {
// Clear mocks
vi.clearAllMocks()
// Create mock canvas element
mockCanvas = document.createElement('canvas')
mockCanvas.dispatchEvent = vi.fn()
app.canvas!.canvas = mockCanvas
// Mock setting store
mockSettingStore = { get: vi.fn() }
vi.mocked(settingStore.useSettingStore).mockReturnValue(
mockSettingStore as any
)
canvasInteractions = useCanvasInteractions()
})
describe('handleWheel', () => {
it('should check navigation mode from settings', () => {
mockSettingStore.get.mockReturnValue('standard')
const wheelEvent = new WheelEvent('wheel', {
ctrlKey: true,
deltaY: -100
})
canvasInteractions.handleWheel(wheelEvent)
expect(mockSettingStore.get).toHaveBeenCalledWith(
'Comfy.Canvas.NavigationMode'
)
})
it('should not forward regular wheel events in standard mode', () => {
mockSettingStore.get.mockReturnValue('standard')
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100
})
canvasInteractions.handleWheel(wheelEvent)
expect(mockCanvas.dispatchEvent).not.toHaveBeenCalled()
})
it('should forward all wheel events to canvas in legacy mode', () => {
mockSettingStore.get.mockReturnValue('legacy')
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
cancelable: true
})
canvasInteractions.handleWheel(wheelEvent)
expect(mockCanvas.dispatchEvent).toHaveBeenCalled()
})
it('should handle missing canvas gracefully', () => {
;(app.canvas as any).canvas = null
mockSettingStore.get.mockReturnValue('standard')
const wheelEvent = new WheelEvent('wheel', {
ctrlKey: true,
deltaY: -100
})
expect(() => {
canvasInteractions.handleWheel(wheelEvent)
}).not.toThrow()
})
})
describe('forwardEventToCanvas', () => {
it('should dispatch event to canvas element', () => {
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100,
ctrlKey: true
})
canvasInteractions.forwardEventToCanvas(wheelEvent)
expect(mockCanvas.dispatchEvent).toHaveBeenCalledWith(
expect.any(WheelEvent)
)
})
it('should handle missing canvas gracefully', () => {
;(app.canvas as any).canvas = null
const wheelEvent = new WheelEvent('wheel', {
deltaY: -100
})
expect(() => {
canvasInteractions.forwardEventToCanvas(wheelEvent)
}).not.toThrow()
})
})
})

View File

@@ -0,0 +1,270 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { type Ref, ref } from 'vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
// Test interfaces
interface TestNodeConfig {
type?: string
mode?: LGraphEventMode
flags?: { collapsed?: boolean }
pinned?: boolean
removable?: boolean
}
interface TestNode {
type: string
mode: LGraphEventMode
flags?: { collapsed?: boolean }
pinned?: boolean
removable?: boolean
isSubgraphNode: () => boolean
}
type MockedItem = TestNode | { type: string; isNode: boolean }
// Mock all stores
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: vi.fn()
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: vi.fn()
}))
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
useNodeHelpStore: vi.fn()
}))
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: vi.fn()
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn()
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
filterOutputNodes: vi.fn()
}))
const createTestNode = (config: TestNodeConfig = {}): TestNode => {
return {
type: config.type || 'TestNode',
mode: config.mode || LGraphEventMode.ALWAYS,
flags: config.flags,
pinned: config.pinned,
removable: config.removable,
isSubgraphNode: () => false
}
}
// Mock comment/connection objects
const mockComment = { type: 'comment', isNode: false }
const mockConnection = { type: 'connection', isNode: false }
describe('useSelectionState', () => {
// Mock store instances
let mockSelectedItems: Ref<MockedItem[]>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
// Setup mock canvas store with proper ref
mockSelectedItems = ref([])
vi.mocked(useCanvasStore).mockReturnValue({
selectedItems: mockSelectedItems,
// Add minimal required properties for the store
$id: 'canvas',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock node def store
vi.mocked(useNodeDefStore).mockReturnValue({
fromLGraphNode: vi.fn((node: TestNode) => {
if (node?.type === 'TestNode') {
return { nodePath: 'test.TestNode', name: 'TestNode' }
}
return null
}),
// Add minimal required properties for the store
$id: 'nodeDef',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock sidebar tab store
const mockToggleSidebarTab = vi.fn()
vi.mocked(useSidebarTabStore).mockReturnValue({
activeSidebarTabId: null,
toggleSidebarTab: mockToggleSidebarTab,
// Add minimal required properties for the store
$id: 'sidebarTab',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
} as any)
// Setup mock node help store
const mockOpenHelp = vi.fn()
const mockCloseHelp = vi.fn()
const mockNodeHelpStore = {
isHelpOpen: false,
currentHelpNode: null,
openHelp: mockOpenHelp,
closeHelp: mockCloseHelp,
// Add minimal required properties for the store
$id: 'nodeHelp',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$onAction: vi.fn(),
$dispose: vi.fn(),
_customProperties: new Set(),
_p: {} as any
}
vi.mocked(useNodeHelpStore).mockReturnValue(mockNodeHelpStore as any)
// Setup mock composables
vi.mocked(useNodeLibrarySidebarTab).mockReturnValue({
id: 'node-library-tab',
title: 'Node Library',
type: 'custom',
render: () => null
} as any)
// Setup mock utility functions
vi.mocked(isLGraphNode).mockImplementation((item: unknown) => {
const typedItem = item as { isNode?: boolean }
return typedItem?.isNode !== false
})
vi.mocked(isImageNode).mockImplementation((node: unknown) => {
const typedNode = node as { type?: string }
return typedNode?.type === 'ImageNode'
})
vi.mocked(filterOutputNodes).mockImplementation(
(nodes: TestNode[]) => nodes.filter((n) => n.type === 'OutputNode') as any
)
})
describe('Selection Detection', () => {
test('should return false when nothing selected', () => {
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(false)
})
test('should return true when items selected', () => {
// Update the mock data before creating the composable
const node1 = createTestNode()
const node2 = createTestNode()
mockSelectedItems.value = [node1, node2]
const { hasAnySelection } = useSelectionState()
expect(hasAnySelection.value).toBe(true)
})
})
describe('Node Type Filtering', () => {
test('should pick only LGraphNodes from mixed selections', () => {
// Update the mock data before creating the composable
const graphNode = createTestNode()
mockSelectedItems.value = [graphNode, mockComment, mockConnection]
const { selectedNodes } = useSelectionState()
expect(selectedNodes.value).toHaveLength(1)
expect(selectedNodes.value[0]).toEqual(graphNode)
})
})
describe('Node State Computation', () => {
test('should detect bypassed nodes', () => {
// Update the mock data before creating the composable
const bypassedNode = createTestNode({ mode: LGraphEventMode.BYPASS })
mockSelectedItems.value = [bypassedNode]
const { selectedNodes } = useSelectionState()
const isBypassed = selectedNodes.value.some(
(n) => n.mode === LGraphEventMode.BYPASS
)
expect(isBypassed).toBe(true)
})
test('should detect pinned/collapsed states', () => {
// Update the mock data before creating the composable
const pinnedNode = createTestNode({ pinned: true })
const collapsedNode = createTestNode({ flags: { collapsed: true } })
mockSelectedItems.value = [pinnedNode, collapsedNode]
const { selectedNodes } = useSelectionState()
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
const isCollapsed = selectedNodes.value.some(
(n) => n.flags?.collapsed === true
)
const isBypassed = selectedNodes.value.some(
(n) => n.mode === LGraphEventMode.BYPASS
)
expect(isPinned).toBe(true)
expect(isCollapsed).toBe(true)
expect(isBypassed).toBe(false)
})
test('should provide non-reactive state computation', () => {
// Update the mock data before creating the composable
const node = createTestNode({ pinned: true })
mockSelectedItems.value = [node]
const { selectedNodes } = useSelectionState()
const isPinned = selectedNodes.value.some((n) => n.pinned === true)
const isCollapsed = selectedNodes.value.some(
(n) => n.flags?.collapsed === true
)
const isBypassed = selectedNodes.value.some(
(n) => n.mode === LGraphEventMode.BYPASS
)
expect(isPinned).toBe(true)
expect(isCollapsed).toBe(false)
expect(isBypassed).toBe(false)
// Test with empty selection using new composable instance
mockSelectedItems.value = []
const { selectedNodes: newSelectedNodes } = useSelectionState()
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)
})
})
})

View File

@@ -0,0 +1,277 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
describe('useTransformSettling', () => {
let element: HTMLDivElement
beforeEach(() => {
vi.useFakeTimers()
element = document.createElement('div')
document.body.appendChild(element)
})
afterEach(() => {
vi.useRealTimers()
document.body.removeChild(element)
})
it('should track wheel events and settle after delay', async () => {
const { isTransforming } = useTransformSettling(element)
// Initially not transforming
expect(isTransforming.value).toBe(false)
// Dispatch wheel event
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
// Should be transforming
expect(isTransforming.value).toBe(true)
// Advance time but not past settle delay
vi.advanceTimersByTime(100)
expect(isTransforming.value).toBe(true)
// Advance past settle delay (default 200ms)
vi.advanceTimersByTime(150)
expect(isTransforming.value).toBe(false)
})
it('should reset settle timer on subsequent wheel events', async () => {
const { isTransforming } = useTransformSettling(element, {
settleDelay: 300
})
// First wheel event
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(true)
// Advance time partially
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(true)
// Another wheel event should reset the timer
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
// Advance 200ms more - should still be transforming
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(true)
// Need another 100ms to settle (300ms total from last event)
vi.advanceTimersByTime(100)
expect(isTransforming.value).toBe(false)
})
it('should track pan events when trackPan is enabled', async () => {
const { isTransforming } = useTransformSettling(element, {
trackPan: true,
settleDelay: 200
})
// Pointer down should start transform
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(true)
// Pointer move should keep it active
vi.advanceTimersByTime(100)
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
await nextTick()
// Should still be transforming
expect(isTransforming.value).toBe(true)
// Pointer up
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
await nextTick()
// Should still be transforming until settle delay
expect(isTransforming.value).toBe(true)
// Advance past settle delay
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(false)
})
it('should not track pan events when trackPan is disabled', async () => {
const { isTransforming } = useTransformSettling(element, {
trackPan: false
})
// Pointer events should not trigger transform
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(false)
})
it('should handle pointer cancel events', async () => {
const { isTransforming } = useTransformSettling(element, {
trackPan: true,
settleDelay: 200
})
// Start panning
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(true)
// Cancel instead of up
element.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
await nextTick()
// Should still settle normally
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(false)
})
it('should work with ref target', async () => {
const targetRef = ref<HTMLElement | null>(null)
const { isTransforming } = useTransformSettling(targetRef)
// No target yet
expect(isTransforming.value).toBe(false)
// Set target
targetRef.value = element
await nextTick()
// Now events should work
element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
expect(isTransforming.value).toBe(true)
vi.advanceTimersByTime(200)
expect(isTransforming.value).toBe(false)
})
it('should use capture phase for events', async () => {
const captureHandler = vi.fn()
const bubbleHandler = vi.fn()
// Add handlers to verify capture phase
element.addEventListener('wheel', captureHandler, true)
element.addEventListener('wheel', bubbleHandler, false)
const { isTransforming } = useTransformSettling(element)
// Create child element
const child = document.createElement('div')
element.appendChild(child)
// Dispatch event on child
child.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
await nextTick()
// Capture handler should be called before bubble handler
expect(captureHandler).toHaveBeenCalled()
expect(isTransforming.value).toBe(true)
element.removeEventListener('wheel', captureHandler, true)
element.removeEventListener('wheel', bubbleHandler, false)
})
it('should throttle pointer move events', async () => {
const { isTransforming } = useTransformSettling(element, {
trackPan: true,
pointerMoveThrottle: 50,
settleDelay: 100
})
// Start panning
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
await nextTick()
// Fire many pointer move events rapidly
for (let i = 0; i < 10; i++) {
element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
vi.advanceTimersByTime(5) // 5ms between events
}
await nextTick()
// Should still be transforming
expect(isTransforming.value).toBe(true)
// End panning
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
// Advance past settle delay
vi.advanceTimersByTime(100)
expect(isTransforming.value).toBe(false)
})
it('should clean up event listeners when component unmounts', async () => {
const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener')
// Create a test component
const TestComponent = {
setup() {
const { isTransforming } = useTransformSettling(element, {
trackPan: true
})
return { isTransforming }
},
template: '<div>{{ isTransforming }}</div>'
}
const wrapper = mount(TestComponent)
await nextTick()
// Unmount component
wrapper.unmount()
// Should have removed all event listeners
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'wheel',
expect.any(Function),
expect.objectContaining({ capture: true })
)
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
expect.objectContaining({ capture: true })
)
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointermove',
expect.any(Function),
expect.objectContaining({ capture: true })
)
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
expect.objectContaining({ capture: true })
)
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'pointercancel',
expect.any(Function),
expect.objectContaining({ capture: true })
)
})
it('should use passive listeners when specified', async () => {
const addEventListenerSpy = vi.spyOn(element, 'addEventListener')
useTransformSettling(element, {
passive: true,
trackPan: true
})
// Check that passive option was used for appropriate events
expect(addEventListenerSpy).toHaveBeenCalledWith(
'wheel',
expect.any(Function),
expect.objectContaining({ passive: true, capture: true })
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointermove',
expect.any(Function),
expect.objectContaining({ passive: true, capture: true })
)
})
})

View File

@@ -0,0 +1,503 @@
import {
type MockedFunction,
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest'
import { ref } from 'vue'
import {
useBooleanWidgetValue,
useNumberWidgetValue,
useStringWidgetValue,
useWidgetValue
} from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
describe('useWidgetValue', () => {
let mockWidget: SimplifiedWidget<string>
let mockEmit: MockedFunction<(event: 'update:modelValue', value: any) => void>
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
mockWidget = {
name: 'testWidget',
type: 'string',
value: 'initial',
callback: vi.fn()
}
mockEmit = vi.fn()
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
consoleWarnSpy.mockRestore()
})
describe('basic functionality', () => {
it('should initialize with modelValue', () => {
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: 'test value',
defaultValue: '',
emit: mockEmit
})
expect(localValue.value).toBe('test value')
})
it('should use defaultValue when modelValue is null', () => {
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: null as any,
defaultValue: 'default',
emit: mockEmit
})
expect(localValue.value).toBe('default')
})
it('should use defaultValue when modelValue is undefined', () => {
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: undefined as any,
defaultValue: 'default',
emit: mockEmit
})
expect(localValue.value).toBe('default')
})
})
describe('onChange handler', () => {
it('should update localValue immediately', () => {
const { localValue, onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit
})
onChange('new value')
expect(localValue.value).toBe('new value')
})
it('should emit update:modelValue event', () => {
const { onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit
})
onChange('new value')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new value')
})
// useGraphNodeMaanger's createWrappedWidgetCallback makes the callback right now instead of useWidgetValue
// it('should call widget callback if it exists', () => {
// const { onChange } = useWidgetValue({
// widget: mockWidget,
// modelValue: 'initial',
// defaultValue: '',
// emit: mockEmit
// })
// onChange('new value')
// expect(mockWidget.callback).toHaveBeenCalledWith('new value')
// })
it('should not error if widget callback is undefined', () => {
const widgetWithoutCallback = { ...mockWidget, callback: undefined }
const { onChange } = useWidgetValue({
widget: widgetWithoutCallback,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit
})
expect(() => onChange('new value')).not.toThrow()
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new value')
})
it('should handle null values', () => {
const { localValue, onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: 'default',
emit: mockEmit
})
onChange(null as any)
expect(localValue.value).toBe('default')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'default')
})
it('should handle undefined values', () => {
const { localValue, onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: 'default',
emit: mockEmit
})
onChange(undefined as any)
expect(localValue.value).toBe('default')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'default')
})
})
describe('type safety', () => {
it('should handle type mismatches with warning', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 42,
callback: vi.fn()
}
const { onChange } = useWidgetValue({
widget: numberWidget,
modelValue: 10,
defaultValue: 0,
emit: mockEmit
})
// Pass string to number widget
onChange('not a number' as any)
expect(consoleWarnSpy).toHaveBeenCalledWith(
'useWidgetValue: Type mismatch for widget numberWidget. Expected number, got string'
)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0) // Uses defaultValue
})
it('should accept values of matching type', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 42,
callback: vi.fn()
}
const { onChange } = useWidgetValue({
widget: numberWidget,
modelValue: 10,
defaultValue: 0,
emit: mockEmit
})
onChange(25)
expect(consoleWarnSpy).not.toHaveBeenCalled()
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 25)
})
})
describe('transform function', () => {
it('should apply transform function to new values', () => {
const transform = vi.fn((value: string) => value.toUpperCase())
const { onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit,
transform
})
onChange('hello')
expect(transform).toHaveBeenCalledWith('hello')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'HELLO')
})
it('should skip type checking when transform is provided', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 42,
callback: vi.fn()
}
const transform = (value: string) => parseInt(value, 10) || 0
const { onChange } = useWidgetValue({
widget: numberWidget,
modelValue: 10,
defaultValue: 0,
emit: mockEmit,
transform
})
onChange('123')
expect(consoleWarnSpy).not.toHaveBeenCalled()
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 123)
})
})
describe('external updates', () => {
it('should update localValue when modelValue changes', async () => {
const modelValue = ref('initial')
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: modelValue.value,
defaultValue: '',
emit: mockEmit
})
expect(localValue.value).toBe('initial')
// Simulate parent updating modelValue
modelValue.value = 'updated externally'
// Re-create the composable with new value (simulating prop change)
const { localValue: newLocalValue } = useWidgetValue({
widget: mockWidget,
modelValue: modelValue.value,
defaultValue: '',
emit: mockEmit
})
expect(newLocalValue.value).toBe('updated externally')
})
it('should handle external null values', async () => {
const modelValue = ref<string | null>('initial')
const { localValue } = useWidgetValue({
widget: mockWidget,
modelValue: modelValue.value!,
defaultValue: 'default',
emit: mockEmit
})
expect(localValue.value).toBe('initial')
// Simulate external update to null
modelValue.value = null
const { localValue: newLocalValue } = useWidgetValue({
widget: mockWidget,
modelValue: modelValue.value as any,
defaultValue: 'default',
emit: mockEmit
})
expect(newLocalValue.value).toBe('default')
})
})
describe('useStringWidgetValue helper', () => {
it('should handle string values correctly', () => {
const stringWidget: SimplifiedWidget<string> = {
name: 'textWidget',
type: 'string',
value: 'hello',
callback: vi.fn()
}
const { localValue, onChange } = useStringWidgetValue(
stringWidget,
'initial',
mockEmit
)
expect(localValue.value).toBe('initial')
onChange('new string')
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new string')
})
it('should transform undefined to empty string', () => {
const stringWidget: SimplifiedWidget<string> = {
name: 'textWidget',
type: 'string',
value: '',
callback: vi.fn()
}
const { onChange } = useStringWidgetValue(stringWidget, '', mockEmit)
onChange(undefined as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '')
})
it('should convert non-string values to string', () => {
const stringWidget: SimplifiedWidget<string> = {
name: 'textWidget',
type: 'string',
value: '',
callback: vi.fn()
}
const { onChange } = useStringWidgetValue(stringWidget, '', mockEmit)
onChange(123 as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '123')
})
})
describe('useNumberWidgetValue helper', () => {
it('should handle number values correctly', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'sliderWidget',
type: 'number',
value: 50,
callback: vi.fn()
}
const { localValue, onChange } = useNumberWidgetValue(
numberWidget,
25,
mockEmit
)
expect(localValue.value).toBe(25)
onChange(75)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 75)
})
it('should handle array values from PrimeVue Slider', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'sliderWidget',
type: 'number',
value: 50,
callback: vi.fn()
}
const { onChange } = useNumberWidgetValue(numberWidget, 25, mockEmit)
// PrimeVue Slider can emit number[]
onChange([42, 100] as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 42)
})
it('should handle empty array', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'sliderWidget',
type: 'number',
value: 50,
callback: vi.fn()
}
const { onChange } = useNumberWidgetValue(numberWidget, 25, mockEmit)
onChange([] as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0)
})
it('should convert string numbers', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 0,
callback: vi.fn()
}
const { onChange } = useNumberWidgetValue(numberWidget, 0, mockEmit)
onChange('42' as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 42)
})
it('should handle invalid number conversions', () => {
const numberWidget: SimplifiedWidget<number> = {
name: 'numberWidget',
type: 'number',
value: 0,
callback: vi.fn()
}
const { onChange } = useNumberWidgetValue(numberWidget, 0, mockEmit)
onChange('not-a-number' as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0)
})
})
describe('useBooleanWidgetValue helper', () => {
it('should handle boolean values correctly', () => {
const boolWidget: SimplifiedWidget<boolean> = {
name: 'toggleWidget',
type: 'boolean',
value: false,
callback: vi.fn()
}
const { localValue, onChange } = useBooleanWidgetValue(
boolWidget,
true,
mockEmit
)
expect(localValue.value).toBe(true)
onChange(false)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', false)
})
it('should convert truthy values to true', () => {
const boolWidget: SimplifiedWidget<boolean> = {
name: 'toggleWidget',
type: 'boolean',
value: false,
callback: vi.fn()
}
const { onChange } = useBooleanWidgetValue(boolWidget, false, mockEmit)
onChange('truthy' as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', true)
})
it('should convert falsy values to false', () => {
const boolWidget: SimplifiedWidget<boolean> = {
name: 'toggleWidget',
type: 'boolean',
value: false,
callback: vi.fn()
}
const { onChange } = useBooleanWidgetValue(boolWidget, true, mockEmit)
onChange(0 as any)
expect(mockEmit).toHaveBeenCalledWith('update:modelValue', false)
})
})
describe('edge cases', () => {
it('should handle rapid onChange calls', () => {
const { onChange } = useWidgetValue({
widget: mockWidget,
modelValue: 'initial',
defaultValue: '',
emit: mockEmit
})
onChange('value1')
onChange('value2')
onChange('value3')
expect(mockEmit).toHaveBeenCalledTimes(3)
expect(mockEmit).toHaveBeenNthCalledWith(1, 'update:modelValue', 'value1')
expect(mockEmit).toHaveBeenNthCalledWith(2, 'update:modelValue', 'value2')
expect(mockEmit).toHaveBeenNthCalledWith(3, 'update:modelValue', 'value3')
})
it('should handle widget with all properties undefined', () => {
const minimalWidget = {
name: 'minimal',
type: 'unknown'
} as SimplifiedWidget<any>
const { localValue, onChange } = useWidgetValue({
widget: minimalWidget,
modelValue: 'test',
defaultValue: 'default',
emit: mockEmit
})
expect(localValue.value).toBe('test')
expect(() => onChange('new')).not.toThrow()
})
})
})

View File

@@ -8,7 +8,12 @@ import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
function createMockNode(
nodeTypeName: string,
widgets: Array<{ name: string; value: any }> = [],
isApiNode = true
isApiNode = true,
inputs: Array<{
name: string
connected?: boolean
useLinksArray?: boolean
}> = []
): LGraphNode {
const mockWidgets = widgets.map(({ name, value }) => ({
name,
@@ -16,7 +21,16 @@ function createMockNode(
type: 'combo'
})) as IComboWidget[]
return {
const mockInputs =
inputs.length > 0
? inputs.map(({ name, connected, useLinksArray }) =>
useLinksArray
? { name, links: connected ? [1] : [] }
: { name, link: connected ? 1 : null }
)
: undefined
const node: any = {
id: Math.random().toString(),
widgets: mockWidgets,
constructor: {
@@ -25,7 +39,24 @@ function createMockNode(
api_node: isApiNode
}
}
} as unknown as LGraphNode
}
if (mockInputs) {
node.inputs = mockInputs
// Provide the common helpers some frontend code may call
node.findInputSlot = function (portName: string) {
return this.inputs?.findIndex((i: any) => i.name === portName) ?? -1
}
node.isInputConnected = function (idx: number) {
const port = this.inputs?.[idx]
if (!port) return false
if (typeof port.link !== 'undefined') return port.link != null
if (Array.isArray(port.links)) return port.links.length > 0
return false
}
}
return node as LGraphNode
}
describe('useNodePricing', () => {
@@ -363,34 +394,51 @@ describe('useNodePricing', () => {
})
describe('dynamic pricing - IdeogramV3', () => {
it('should return $0.09 for Quality rendering speed', () => {
it('should return correct prices for IdeogramV3 node', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Quality' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.09/Run')
})
const testCases = [
{
rendering_speed: 'Quality',
character_image: false,
expected: '$0.09/Run'
},
{
rendering_speed: 'Quality',
character_image: true,
expected: '$0.20/Run'
},
{
rendering_speed: 'Default',
character_image: false,
expected: '$0.06/Run'
},
{
rendering_speed: 'Default',
character_image: true,
expected: '$0.15/Run'
},
{
rendering_speed: 'Turbo',
character_image: false,
expected: '$0.03/Run'
},
{
rendering_speed: 'Turbo',
character_image: true,
expected: '$0.10/Run'
}
]
it('should return $0.06 for Balanced rendering speed', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Balanced' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.06/Run')
})
it('should return $0.03 for Turbo rendering speed', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Turbo' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03/Run')
testCases.forEach(({ rendering_speed, character_image, expected }) => {
const node = createMockNode(
'IdeogramV3',
[{ name: 'rendering_speed', value: rendering_speed }],
true,
[{ name: 'character_image', connected: character_image }]
)
expect(getNodeDisplayPrice(node)).toBe(expected)
})
})
it('should return range when rendering_speed widget is missing', () => {
@@ -457,7 +505,7 @@ describe('useNodePricing', () => {
})
describe('dynamic pricing - Veo3VideoGenerationNode', () => {
it('should return $2.00 for veo-3.0-fast-generate-001 without audio', () => {
it('should return $0.80 for veo-3.0-fast-generate-001 without audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-fast-generate-001' },
@@ -465,49 +513,49 @@ describe('useNodePricing', () => {
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$2.00/Run')
expect(price).toBe('$0.80/Run')
})
it('should return $3.20 for veo-3.0-fast-generate-001 with audio', () => {
it('should return $1.20 for veo-3.0-fast-generate-001 with audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-fast-generate-001' },
{ name: 'generate_audio', value: true }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.20/Run')
})
it('should return $1.60 for veo-3.0-generate-001 without audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-generate-001' },
{ name: 'generate_audio', value: false }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.60/Run')
})
it('should return $3.20 for veo-3.0-generate-001 with audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-generate-001' },
{ name: 'generate_audio', value: true }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$3.20/Run')
})
it('should return $4.00 for veo-3.0-generate-001 without audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-generate-001' },
{ name: 'generate_audio', value: false }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$4.00/Run')
})
it('should return $6.00 for veo-3.0-generate-001 with audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-generate-001' },
{ name: 'generate_audio', value: true }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$6.00/Run')
})
it('should return range when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$2.00-6.00/Run (varies with model & audio generation)'
'$0.80-3.20/Run (varies with model & audio generation)'
)
})
@@ -519,7 +567,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$2.00-6.00/Run (varies with model & audio generation)'
'$0.80-3.20/Run (varies with model & audio generation)'
)
})
@@ -531,7 +579,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$2.00-6.00/Run (varies with model & audio generation)'
'$0.80-3.20/Run (varies with model & audio generation)'
)
})
})
@@ -935,7 +983,11 @@ describe('useNodePricing', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('IdeogramV3')
expect(widgetNames).toEqual(['rendering_speed', 'num_images'])
expect(widgetNames).toEqual([
'rendering_speed',
'num_images',
'character_image'
])
})
})
@@ -1728,4 +1780,273 @@ describe('useNodePricing', () => {
})
})
})
describe('dynamic pricing - ByteDanceSeedreamNode', () => {
it('should return fallback when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceSeedreamNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03/Run ($0.03 for one output image)')
})
it('should return $0.03/Run when sequential generation is disabled', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceSeedreamNode', [
{ name: 'sequential_image_generation', value: 'disabled' },
{ name: 'max_images', value: 5 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03/Run')
})
it('should multiply by max_images when sequential generation is enabled', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceSeedreamNode', [
{ name: 'sequential_image_generation', value: 'enabled' },
{ name: 'max_images', value: 4 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.12/Run ($0.03 for one output image)')
})
})
describe('dynamic pricing - ByteDance Seedance video nodes', () => {
it('should return base 10s range for PRO 1080p on ByteDanceTextToVideoNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceTextToVideoNode', [
{ name: 'model', value: 'seedance-1-0-pro' },
{ name: 'duration', value: '10' },
{ name: 'resolution', value: '1080p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.18-$1.22/Run')
})
it('should scale to half for 5s PRO 1080p on ByteDanceTextToVideoNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceTextToVideoNode', [
{ name: 'model', value: 'seedance-1-0-pro' },
{ name: 'duration', value: '5' },
{ name: 'resolution', value: '1080p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.59-$0.61/Run')
})
it('should scale for 8s PRO 480p on ByteDanceImageToVideoNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceImageToVideoNode', [
{ name: 'model', value: 'seedance-1-0-pro' },
{ name: 'duration', value: '8' },
{ name: 'resolution', value: '480p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.18-$0.19/Run')
})
it('should scale correctly for 12s PRO 720p on ByteDanceFirstLastFrameNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceFirstLastFrameNode', [
{ name: 'model', value: 'seedance-1-0-pro' },
{ name: 'duration', value: '12' },
{ name: 'resolution', value: '720p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.61-$0.67/Run')
})
it('should collapse to a single value when min and max round equal for LITE 480p 3s on ByteDanceImageReferenceNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceImageReferenceNode', [
{ name: 'model', value: 'seedance-1-0-lite' },
{ name: 'duration', value: '3' },
{ name: 'resolution', value: '480p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05/Run') // 0.17..0.18 scaled by 0.3 both round to 0.05
})
it('should return Token-based when required widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const missingModel = createMockNode('ByteDanceFirstLastFrameNode', [
{ name: 'duration', value: '10' },
{ name: 'resolution', value: '1080p' }
])
const missingResolution = createMockNode('ByteDanceImageToVideoNode', [
{ name: 'model', value: 'seedance-1-0-pro' },
{ name: 'duration', value: '10' }
])
const missingDuration = createMockNode('ByteDanceTextToVideoNode', [
{ name: 'model', value: 'seedance-1-0-lite' },
{ name: 'resolution', value: '720p' }
])
expect(getNodeDisplayPrice(missingModel)).toBe('Token-based')
expect(getNodeDisplayPrice(missingResolution)).toBe('Token-based')
expect(getNodeDisplayPrice(missingDuration)).toBe('Token-based')
})
})
describe('dynamic pricing - WanTextToVideoApi', () => {
it('should return $1.50 for 10s at 1080p', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('WanTextToVideoApi', [
{ name: 'duration', value: '10' },
{ name: 'size', value: '1080p: 4:3 (1632x1248)' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.50/Run') // 0.15 * 10
})
it('should return $0.50 for 5s at 720p', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('WanTextToVideoApi', [
{ name: 'duration', value: 5 },
{ name: 'size', value: '720p: 16:9 (1280x720)' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.50/Run') // 0.10 * 5
})
it('should return $0.15 for 3s at 480p', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('WanTextToVideoApi', [
{ name: 'duration', value: '3' },
{ name: 'size', value: '480p: 1:1 (624x624)' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.15/Run') // 0.05 * 3
})
it('should fall back when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const missingBoth = createMockNode('WanTextToVideoApi', [])
const missingSize = createMockNode('WanTextToVideoApi', [
{ name: 'duration', value: '5' }
])
const missingDuration = createMockNode('WanTextToVideoApi', [
{ name: 'size', value: '1080p' }
])
expect(getNodeDisplayPrice(missingBoth)).toBe('$0.05-0.15/second')
expect(getNodeDisplayPrice(missingSize)).toBe('$0.05-0.15/second')
expect(getNodeDisplayPrice(missingDuration)).toBe('$0.05-0.15/second')
})
it('should fall back on invalid duration', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('WanTextToVideoApi', [
{ name: 'duration', value: 'invalid' },
{ name: 'size', value: '1080p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05-0.15/second')
})
it('should fall back on unknown resolution', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('WanTextToVideoApi', [
{ name: 'duration', value: '10' },
{ name: 'size', value: '2K' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05-0.15/second')
})
})
describe('dynamic pricing - WanImageToVideoApi', () => {
it('should return $0.80 for 8s at 720p', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('WanImageToVideoApi', [
{ name: 'duration', value: 8 },
{ name: 'resolution', value: '720p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.80/Run') // 0.10 * 8
})
it('should return $0.60 for 12s at 480P', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('WanImageToVideoApi', [
{ name: 'duration', value: '12' },
{ name: 'resolution', value: '480P' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.60/Run') // 0.05 * 12
})
it('should return $1.50 for 10s at 1080p', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('WanImageToVideoApi', [
{ name: 'duration', value: '10' },
{ name: 'resolution', value: '1080p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.50/Run') // 0.15 * 10
})
it('should handle "5s" string duration at 1080P', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('WanImageToVideoApi', [
{ name: 'duration', value: '5s' },
{ name: 'resolution', value: '1080P' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.75/Run') // 0.15 * 5
})
it('should fall back when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const missingBoth = createMockNode('WanImageToVideoApi', [])
const missingRes = createMockNode('WanImageToVideoApi', [
{ name: 'duration', value: '5' }
])
const missingDuration = createMockNode('WanImageToVideoApi', [
{ name: 'resolution', value: '1080p' }
])
expect(getNodeDisplayPrice(missingBoth)).toBe('$0.05-0.15/second')
expect(getNodeDisplayPrice(missingRes)).toBe('$0.05-0.15/second')
expect(getNodeDisplayPrice(missingDuration)).toBe('$0.05-0.15/second')
})
it('should fall back on invalid duration', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('WanImageToVideoApi', [
{ name: 'duration', value: 'invalid' },
{ name: 'resolution', value: '720p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05-0.15/second')
})
it('should fall back on unknown resolution', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('WanImageToVideoApi', [
{ name: 'duration', value: '10' },
{ name: 'resolution', value: 'weird-res' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05-0.15/second')
})
})
})

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: ReturnType<typeof vi.fn>
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')
])
mockIsPackInstalled.mockImplementation((id: string) => {
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')
])
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')])
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')
])
mockIsPackInstalled.mockImplementation((id: string) => {
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')
])
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')
])
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')
])
mockIsPackInstalled.mockImplementation((id: string) => 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')
])
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')
])
mockIsPackInstalled.mockImplementation((id: string) => 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')
])
mockIsPackInstalled.mockImplementation((id: string) => {
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')
])
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')
])
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')
])
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')
])
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')
])
mockIsPackInstalled.mockImplementation((id: string) => 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')
])
mockIsPackInstalled.mockReturnValue(false)
const { selectionState } = usePacksSelection(nodePacks)
expect(selectionState.value).toBe('none-installed')
// Change mock to simulate installation
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')
])
mockIsPackInstalled.mockImplementation((id: string) => 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
}
mockIsPackInstalled.mockImplementation(
(id: string) => 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,186 @@
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,469 @@
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 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 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: []
},
devices: [
{
name: 'Apple M1 Pro',
type: 'mps',
index: 0,
vram_total: 17179869184,
vram_free: 8589934592,
torch_vram_total: 17179869184,
torch_vram_free: 8589934592
}
]
},
isInitialized: ref(true),
$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

@@ -2,21 +2,29 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
vi.mock('@/scripts/app', () => ({
app: {
clean: vi.fn(),
canvas: {
subgraph: null
},
graph: {
clear: vi.fn()
vi.mock('@/scripts/app', () => {
const mockGraphClear = vi.fn()
const mockCanvas = { subgraph: undefined }
return {
app: {
clean: vi.fn(() => {
// Simulate app.clean() calling graph.clear() only when not in subgraph
if (!mockCanvas.subgraph) {
mockGraphClear()
}
}),
canvas: mockCanvas,
graph: {
clear: mockGraphClear
}
}
}
}))
})
vi.mock('@/scripts/api', () => ({
api: {
@@ -25,7 +33,7 @@ vi.mock('@/scripts/api', () => ({
}
}))
vi.mock('@/stores/settingStore')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({}))
@@ -41,7 +49,7 @@ vi.mock('firebase/auth', () => ({
onAuthStateChanged: vi.fn()
}))
vi.mock('@/services/workflowService', () => ({
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => ({}))
}))
@@ -61,10 +69,14 @@ vi.mock('@/stores/toastStore', () => ({
useToastStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/workflowStore', () => ({
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({}))
}))

View File

@@ -0,0 +1,137 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isReactive, isReadonly } from 'vue'
import {
ServerFeatureFlag,
useFeatureFlags
} from '@/composables/useFeatureFlags'
import { api } from '@/scripts/api'
// Mock the API module
vi.mock('@/scripts/api', () => ({
api: {
getServerFeature: vi.fn()
}
}))
describe('useFeatureFlags', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('flags object', () => {
it('should provide reactive readonly flags', () => {
const { flags } = useFeatureFlags()
expect(isReadonly(flags)).toBe(true)
expect(isReactive(flags)).toBe(true)
})
it('should access supportsPreviewMetadata', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
return true as any
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA
)
})
it('should access maxUploadSize', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE)
return 209715200 as any // 200MB
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.maxUploadSize).toBe(209715200)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.MAX_UPLOAD_SIZE
)
})
it('should access supportsManagerV4', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MANAGER_SUPPORTS_V4) return true as any
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.supportsManagerV4).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.MANAGER_SUPPORTS_V4
)
})
it('should return undefined when features are not available and no default provided', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue as any
)
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBeUndefined()
expect(flags.maxUploadSize).toBeUndefined()
expect(flags.supportsManagerV4).toBeUndefined()
})
})
describe('featureFlag', () => {
it('should create reactive computed for custom feature flags', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === 'custom.feature') return 'custom-value' as any
return defaultValue
}
)
const { featureFlag } = useFeatureFlags()
const customFlag = featureFlag('custom.feature', 'default')
expect(customFlag.value).toBe('custom-value')
expect(api.getServerFeature).toHaveBeenCalledWith(
'custom.feature',
'default'
)
})
it('should handle nested paths', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === 'extension.custom.nested.feature') return true as any
return defaultValue
}
)
const { featureFlag } = useFeatureFlags()
const nestedFlag = featureFlag('extension.custom.nested.feature', false)
expect(nestedFlag.value).toBe(true)
})
it('should work with ServerFeatureFlag enum', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE)
return 104857600 as any
return defaultValue
}
)
const { featureFlag } = useFeatureFlags()
const maxUploadSize = featureFlag(ServerFeatureFlag.MAX_UPLOAD_SIZE)
expect(maxUploadSize.value).toBe(104857600)
})
})
})

View File

@@ -3,9 +3,9 @@ import { vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { useFrontendVersionMismatchWarning } from '@/composables/useFrontendVersionMismatchWarning'
import { useToastStore } from '@/stores/toastStore'
import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
// Mock globals
//@ts-expect-error Define global for the test

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 (importOriginal) => {
const actual = await importOriginal<typeof import('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

@@ -4,14 +4,14 @@ import { nextTick } from 'vue'
import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useLoad3dService } from '@/services/load3dService'
import { useToastStore } from '@/stores/toastStore'
vi.mock('@/services/load3dService', () => ({
useLoad3dService: vi.fn()
}))
vi.mock('@/stores/toastStore', () => ({
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn()
}))

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

@@ -115,11 +115,11 @@ const defaultSettingStore = {
set: vi.fn().mockResolvedValue(undefined)
}
vi.mock('@/stores/graphStore', () => ({
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => defaultCanvasStore)
}))
vi.mock('@/stores/settingStore', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => defaultSettingStore)
}))
@@ -147,7 +147,7 @@ vi.mock('@/scripts/app', () => ({
}
}))
vi.mock('@/stores/workflowStore', () => ({
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeSubgraph: null
}))

View File

@@ -1,13 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
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'
// Mock Vue's onMounted to execute immediately for testing
vi.mock('vue', async () => {
@@ -19,11 +19,14 @@ vi.mock('vue', async () => {
})
// Mock the dependencies
vi.mock('@/composables/nodePack/useWorkflowPacks', () => ({
useWorkflowPacks: vi.fn()
}))
vi.mock(
'@/workbench/extensions/manager/composables/nodePack/useWorkflowPacks',
() => ({
useWorkflowPacks: vi.fn()
})
)
vi.mock('@/stores/comfyManagerStore', () => ({
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn()
}))

View File

@@ -1,25 +1,28 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeChatHistory } from '@/composables/node/useNodeChatHistory'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
vi.mock('@/composables/widgets/useChatHistoryWidget', () => ({
useChatHistoryWidget: () => {
return (node: any, inputSpec: any) => {
const widget = {
name: inputSpec.name,
type: inputSpec.type
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useChatHistoryWidget',
() => ({
useChatHistoryWidget: () => {
return (node: any, inputSpec: any) => {
const widget = {
name: inputSpec.name,
type: inputSpec.type
}
if (!node.widgets) {
node.widgets = []
}
node.widgets.push(widget)
return widget
}
if (!node.widgets) {
node.widgets = []
}
node.widgets.push(widget)
return widget
}
}
}))
})
)
// Mock LGraphNode type
type MockNode = {

View File

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useServerLogs } from '@/composables/useServerLogs'
import { LogsWsMessage } from '@/schemas/apiSchema'
import type { LogsWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
vi.mock('@/scripts/api', () => ({

View File

@@ -2,16 +2,19 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useSettingSearch } from '@/composables/setting/useSettingSearch'
import { st } from '@/i18n'
import { getSettingInfo, useSettingStore } from '@/stores/settingStore'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
import {
getSettingInfo,
useSettingStore
} from '@/platform/settings/settingStore'
// Mock dependencies
vi.mock('@/i18n', () => ({
st: vi.fn((_: string, fallback: string) => fallback)
}))
vi.mock('@/stores/settingStore', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(),
getSettingInfo: vi.fn()
}))

View File

@@ -1,13 +1,16 @@
import { flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
// Mock the store
vi.mock('@/stores/workflowTemplatesStore', () => ({
useWorkflowTemplatesStore: vi.fn()
}))
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: vi.fn()
})
)
// Mock the API
vi.mock('@/scripts/api', () => ({
@@ -222,28 +225,22 @@ describe('useTemplateWorkflows', () => {
const { getTemplateDescription } = useTemplateWorkflows()
// Default template with localized description
const descWithLocalized = getTemplateDescription(
{
name: 'test',
localizedDescription: 'Localized Description',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
const descWithLocalized = getTemplateDescription({
name: 'test',
localizedDescription: 'Localized Description',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
})
expect(descWithLocalized).toBe('Localized Description')
// Custom template with description
const customDesc = getTemplateDescription(
{
name: 'test',
description: 'custom-template_description',
mediaType: 'image',
mediaSubtype: 'jpg'
},
'custom-module'
)
const customDesc = getTemplateDescription({
name: 'test',
description: 'custom-template_description',
mediaType: 'image',
mediaSubtype: 'jpg'
})
expect(customDesc).toBe('custom template description')
})

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

@@ -1,9 +1,9 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
import { api } from '@/scripts/api'
import { useWorkflowService } from '@/services/workflowService'
vi.mock('@/scripts/api', () => ({
api: {
@@ -12,13 +12,13 @@ vi.mock('@/scripts/api', () => ({
}
}))
vi.mock('@/services/workflowService', () => ({
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => ({
saveWorkflow: vi.fn()
}))
}))
vi.mock('@/stores/settingStore', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn((key) => {
if (key === 'Comfy.Workflow.AutoSave') return mockAutoSaveSetting
@@ -28,7 +28,7 @@ vi.mock('@/stores/settingStore', () => ({
}))
}))
vi.mock('@/stores/workflowStore', () => ({
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
activeWorkflow: mockActiveWorkflow
}))

View File

@@ -1,706 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useComboWidget } from '@/composables/widgets/useComboWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { fileNameMappingService } from '@/services/fileNameMappingService'
// Mock api to prevent app initialization
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
addEventListener: vi.fn(),
apiURL: vi.fn((path) => `/api${path}`),
fileURL: vi.fn((path) => path)
}
}))
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
vi.mock('@/services/fileNameMappingService', () => ({
fileNameMappingService: {
getMapping: vi.fn().mockResolvedValue({}),
getCachedMapping: vi.fn().mockReturnValue({}),
getCachedReverseMapping: vi.fn().mockReturnValue({}),
refreshMapping: vi.fn().mockResolvedValue({}),
invalidateCache: vi.fn()
}
}))
describe('useComboWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('deduplication', () => {
it('should display deduplicated names in dropdown', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'hash1.png',
options: {
values: ['hash1.png', 'hash2.png', 'hash3.png']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget)
}
// Mock deduplicated mapping
vi.mocked(fileNameMappingService.getCachedMapping).mockImplementation(
(_fileType, deduplicated) => {
if (deduplicated) {
return {
'hash1.png': 'vacation_hash1.png',
'hash2.png': 'vacation_hash2.png',
'hash3.png': 'landscape.png'
}
}
return {
'hash1.png': 'vacation.png',
'hash2.png': 'vacation.png',
'hash3.png': 'landscape.png'
}
}
)
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['hash1.png', 'hash2.png', 'hash3.png']
}
const widget = constructor(mockNode as any, inputSpec)
// Check that dropdown values are deduplicated
const dropdownValues = widget.options.values
expect(dropdownValues).toEqual([
'vacation_hash1.png',
'vacation_hash2.png',
'landscape.png'
])
})
it('should correctly handle selection of deduplicated names', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'hash1.png',
options: {
values: ['hash1.png', 'hash2.png']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget)
}
// Mock deduplicated mappings
vi.mocked(fileNameMappingService.getCachedMapping).mockImplementation(
(_fileType, deduplicated) => {
if (deduplicated) {
return {
'hash1.png': 'image_hash1.png',
'hash2.png': 'image_hash2.png'
}
}
return {
'hash1.png': 'image.png',
'hash2.png': 'image.png'
}
}
)
vi.mocked(
fileNameMappingService.getCachedReverseMapping
).mockImplementation((_fileType, deduplicated) => {
if (deduplicated) {
return {
'image_hash1.png': 'hash1.png',
'image_hash2.png': 'hash2.png'
} as Record<string, string>
}
return {
'image.png': 'hash2.png' // Last one wins in non-dedup
} as Record<string, string>
})
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['hash1.png', 'hash2.png']
}
const widget = constructor(mockNode as any, inputSpec)
// Select deduplicated name
;(widget as any).setValue('image_hash1.png')
// Should set the correct hash value
expect(widget.value).toBe('hash1.png')
})
it('should display correct deduplicated name in _displayValue', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'abc123.png',
options: {
values: ['abc123.png', 'def456.png']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget)
}
// Mock deduplicated mapping
vi.mocked(fileNameMappingService.getCachedMapping).mockImplementation(
(_fileType, deduplicated) => {
if (deduplicated) {
return {
'abc123.png': 'photo_abc123.png',
'def456.png': 'photo_def456.png'
}
}
return {
'abc123.png': 'photo.png',
'def456.png': 'photo.png'
}
}
)
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['abc123.png', 'def456.png']
}
const widget = constructor(mockNode as any, inputSpec)
// Check display value shows deduplicated name
expect((widget as any)._displayValue).toBe('photo_abc123.png')
})
})
it('should handle undefined spec', () => {
const constructor = useComboWidget()
const mockNode = {
addWidget: vi.fn().mockReturnValue({ options: {} } as any)
}
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'inputName'
}
const widget = constructor(mockNode as any, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'inputName',
undefined, // default value
expect.any(Function), // callback
expect.objectContaining({
values: []
})
)
expect(widget).toEqual({ options: {} })
})
describe('filename mapping', () => {
it('should apply filename mapping to widgets with file extensions', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'abc123.png',
options: {
values: ['abc123.png', 'def456.jpg']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget),
setDirtyCanvas: vi.fn(),
graph: {
setDirtyCanvas: vi.fn()
}
}
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['abc123.png', 'def456.jpg', 'xyz789.webp']
}
// Setup mapping service mocks
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
'abc123.png': 'vacation_photo.png',
'def456.jpg': 'profile_picture.jpg',
'xyz789.webp': 'animated_logo.webp'
})
vi.mocked(fileNameMappingService.getCachedReverseMapping).mockReturnValue(
{
'vacation_photo.png': 'abc123.png',
'profile_picture.jpg': 'def456.jpg',
'animated_logo.webp': 'xyz789.webp'
}
)
vi.mocked(fileNameMappingService.getMapping).mockResolvedValue({
'abc123.png': 'vacation_photo.png',
'def456.jpg': 'profile_picture.jpg',
'xyz789.webp': 'animated_logo.webp'
})
const widget = constructor(mockNode as any, inputSpec)
// Widget should have mapping methods
expect(widget).toBeDefined()
expect(typeof (widget as any).refreshMappings).toBe('function')
expect(typeof (widget as any).serializeValue).toBe('function')
})
it('should display human-readable names in dropdown', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'abc123.png',
options: {
values: ['abc123.png', 'def456.jpg']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget),
setDirtyCanvas: vi.fn(),
graph: {
setDirtyCanvas: vi.fn()
}
}
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['abc123.png', 'def456.jpg']
}
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
'abc123.png': 'vacation_photo.png',
'def456.jpg': 'profile_picture.jpg'
})
const widget = constructor(mockNode as any, inputSpec) as any
// Access options.values through the proxy
const dropdownValues = widget.options.values
// Should return human-readable names
expect(dropdownValues).toEqual([
'vacation_photo.png',
'profile_picture.jpg'
])
})
it('should handle selection of human-readable name and convert to hash', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'abc123.png',
options: {
values: ['abc123.png']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget),
setDirtyCanvas: vi.fn(),
graph: {
setDirtyCanvas: vi.fn()
}
}
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['abc123.png']
}
vi.mocked(fileNameMappingService.getCachedReverseMapping).mockReturnValue(
{
'vacation_photo.png': 'abc123.png'
}
)
const widget = constructor(mockNode as any, inputSpec) as any
// Simulate selecting human-readable name
widget.callback('vacation_photo.png')
// Should store hash value
expect(widget.value).toBe('abc123.png')
})
it('should not apply mapping to non-file widgets', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'mode',
value: 'linear',
options: {
values: ['linear', 'cubic', 'nearest']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget)
}
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'mode',
options: ['linear', 'cubic', 'nearest']
}
const widget = constructor(mockNode as any, inputSpec)
// Should not have mapping methods
expect((widget as any).refreshMappings).toBeUndefined()
expect((widget as any).serializeValue).toBeUndefined()
})
it('should show newly uploaded file in dropdown even without mapping', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'abc123.png',
options: {
values: ['abc123.png']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget),
setDirtyCanvas: vi.fn(),
graph: {
setDirtyCanvas: vi.fn()
}
}
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['abc123.png']
}
// Start with mapping for existing file only
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
'abc123.png': 'vacation_photo.png'
})
const widget = constructor(mockNode as any, inputSpec) as any
// Simulate adding new file without mapping yet
const newValues = [...mockWidget.options.values, 'new789.png']
mockWidget.options.values = newValues
// Mapping still doesn't have the new file
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
'abc123.png': 'vacation_photo.png'
})
// Force refresh
widget.refreshMappings()
// Access updated dropdown values
const dropdownValues = widget.options.values
// Should show human name for mapped file and hash for unmapped file
expect(dropdownValues).toEqual(['vacation_photo.png', 'new789.png'])
})
it('should handle dropdown update after new file upload', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'abc123.png',
options: {
values: ['abc123.png']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget),
setDirtyCanvas: vi.fn(),
graph: {
setDirtyCanvas: vi.fn()
}
}
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['abc123.png']
}
// Initial mapping
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
'abc123.png': 'vacation_photo.png'
})
const widget = constructor(mockNode as any, inputSpec) as any
// The proxy should initially return mapped values
expect(widget.options.values).toEqual(['vacation_photo.png'])
// Simulate adding new file by replacing the values array (as happens in practice)
// This is how addToComboValues would modify it
const newValues = [...mockWidget.options.values, 'new789.png']
mockWidget.options.values = newValues
// Update mapping to include the new file
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
'abc123.png': 'vacation_photo.png',
'new789.png': 'new_upload.png'
})
// Force refresh of cached values
widget.refreshMappings()
// Access updated dropdown values - proxy should recompute with new mapping
const dropdownValues = widget.options.values
// Should include both mapped names
expect(dropdownValues).toEqual(['vacation_photo.png', 'new_upload.png'])
})
it('should display hash as fallback when no mapping exists', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'unmapped123.png',
options: {
values: ['unmapped123.png']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget),
setDirtyCanvas: vi.fn(),
graph: {
setDirtyCanvas: vi.fn()
}
}
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['unmapped123.png']
}
// Return empty mapping
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({})
const widget = constructor(mockNode as any, inputSpec) as any
// Access _displayValue
const displayValue = widget._displayValue
// Should show hash when no mapping exists
expect(displayValue).toBe('unmapped123.png')
// Dropdown should also show hash
const dropdownValues = widget.options.values
expect(dropdownValues).toEqual(['unmapped123.png'])
})
it('should serialize widget value as hash for API calls', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'abc123.png',
options: {
values: ['abc123.png']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget),
setDirtyCanvas: vi.fn(),
graph: {
setDirtyCanvas: vi.fn()
}
}
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['abc123.png']
}
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
'abc123.png': 'vacation_photo.png'
})
const widget = constructor(mockNode as any, inputSpec) as any
// serializeValue should always return hash
const serialized = widget.serializeValue()
expect(serialized).toBe('abc123.png')
})
it('should ensure widget.value always contains hash for API calls', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'abc123.png',
options: {
values: ['abc123.png']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget),
setDirtyCanvas: vi.fn(),
graph: {
setDirtyCanvas: vi.fn()
}
}
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['abc123.png']
}
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
'abc123.png': 'vacation.png'
})
vi.mocked(fileNameMappingService.getCachedReverseMapping).mockReturnValue(
{
'vacation.png': 'abc123.png'
}
)
const widget = constructor(mockNode as any, inputSpec) as any
// Simulate user selecting from dropdown (human name)
widget.setValue('vacation.png')
// Widget.value should contain the hash for API calls
expect(widget.value).toBe('abc123.png')
// Callback should also convert human name to hash
widget.callback('vacation.png')
expect(widget.value).toBe('abc123.png')
// The value used for API calls should always be the hash
// This is what would be used in /view?filename=...
const apiValue = widget.value
expect(apiValue).toBe('abc123.png')
})
it('should handle arrow key navigation with filename mapping', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'image',
value: 'abc123.png',
options: {
values: ['abc123.png', 'def456.jpg', 'xyz789.webp']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget),
setDirtyCanvas: vi.fn(),
graph: {
setDirtyCanvas: vi.fn()
}
}
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'image',
options: ['abc123.png', 'def456.jpg', 'xyz789.webp']
}
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
'abc123.png': 'vacation.png',
'def456.jpg': 'profile.jpg',
'xyz789.webp': 'banner.webp'
})
vi.mocked(fileNameMappingService.getCachedReverseMapping).mockReturnValue(
{
'vacation.png': 'abc123.png',
'profile.jpg': 'def456.jpg',
'banner.webp': 'xyz789.webp'
}
)
const widget = constructor(mockNode as any, inputSpec) as any
// Test increment (arrow right/up)
widget.incrementValue({ canvas: { last_mouseclick: 0 } })
// Should move from abc123.png to def456.jpg
expect(widget.value).toBe('def456.jpg')
// Test decrement (arrow left/down)
widget.decrementValue({ canvas: { last_mouseclick: 0 } })
// Should move back to abc123.png
expect(widget.value).toBe('abc123.png')
})
it('should handle mixed file and non-file options', () => {
const constructor = useComboWidget()
const mockWidget = {
name: 'source',
value: 'abc123.png',
options: {
values: ['abc123.png', 'none', 'default']
},
callback: vi.fn()
}
const mockNode = {
addWidget: vi.fn().mockReturnValue(mockWidget),
setDirtyCanvas: vi.fn(),
graph: {
setDirtyCanvas: vi.fn()
}
}
const inputSpec: InputSpec = {
type: 'COMBO',
name: 'source',
options: ['abc123.png', 'none', 'default']
}
vi.mocked(fileNameMappingService.getCachedMapping).mockReturnValue({
'abc123.png': 'background.png'
})
const widget = constructor(mockNode as any, inputSpec) as any
const dropdownValues = widget.options.values
// Should map file, but leave non-files unchanged
expect(dropdownValues).toEqual(['background.png', 'none', 'default'])
})
})
})

View File

@@ -1,329 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useManagerQueue } from '@/composables/useManagerQueue'
import { api } from '@/scripts/api'
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}
}))
describe('useManagerQueue', () => {
const createMockTask = (result: any = 'result') => ({
task: vi.fn().mockResolvedValue(result),
onComplete: vi.fn()
})
const createQueueWithMockTask = () => {
const queue = useManagerQueue()
const mockTask = createMockTask()
queue.enqueueTask(mockTask)
return { queue, mockTask }
}
const getEventListenerCallback = () =>
vi.mocked(api.addEventListener).mock.calls[0][1]
const simulateServerStatus = async (status: 'done' | 'in_progress') => {
const event = new CustomEvent('cm-queue-status', {
detail: { status }
})
getEventListenerCallback()!(event as any)
await nextTick()
}
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
describe('initialization', () => {
it('should initialize with empty queue and DONE status', () => {
const queue = useManagerQueue()
expect(queue.queueLength.value).toBe(0)
expect(queue.statusMessage.value).toBe('done')
expect(queue.allTasksDone.value).toBe(true)
})
})
describe('queue management', () => {
it('should add tasks to the queue', () => {
const queue = useManagerQueue()
const mockTask = createMockTask()
queue.enqueueTask(mockTask)
expect(queue.queueLength.value).toBe(1)
expect(queue.allTasksDone.value).toBe(false)
})
it('should clear the queue when clearQueue is called', () => {
const queue = useManagerQueue()
// Add some tasks
queue.enqueueTask(createMockTask())
queue.enqueueTask(createMockTask())
expect(queue.queueLength.value).toBe(2)
// Clear the queue
queue.clearQueue()
expect(queue.queueLength.value).toBe(0)
expect(queue.allTasksDone.value).toBe(true)
})
})
describe('server status handling', () => {
it('should update server status when receiving websocket events', async () => {
const queue = useManagerQueue()
await simulateServerStatus('in_progress')
expect(queue.statusMessage.value).toBe('in_progress')
expect(queue.allTasksDone.value).toBe(false)
})
it('should handle invalid status values gracefully', async () => {
const queue = useManagerQueue()
// Simulate an invalid status
const event = new CustomEvent('cm-queue-status', {
detail: null as any
})
getEventListenerCallback()!(event)
await nextTick()
// Should maintain the default status
expect(queue.statusMessage.value).toBe('done')
})
it('should handle missing status property gracefully', async () => {
const queue = useManagerQueue()
// Simulate a detail object without status property
const event = new CustomEvent('cm-queue-status', {
detail: { someOtherProperty: 'value' } as any
})
getEventListenerCallback()!(event)
await nextTick()
// Should maintain the default status
expect(queue.statusMessage.value).toBe('done')
})
})
describe('task execution', () => {
it('should start the next task when server is idle and queue has items', async () => {
const { queue, mockTask } = createQueueWithMockTask()
await simulateServerStatus('done')
// Task should have been started
expect(mockTask.task).toHaveBeenCalled()
expect(queue.queueLength.value).toBe(0)
})
it('should execute onComplete callback when task completes and server becomes idle', async () => {
const { mockTask } = createQueueWithMockTask()
// Start the task
await simulateServerStatus('done')
expect(mockTask.task).toHaveBeenCalled()
// Simulate task completion
await mockTask.task.mock.results[0].value
// Simulate server cycle (in_progress -> done)
await simulateServerStatus('in_progress')
expect(mockTask.onComplete).not.toHaveBeenCalled()
await simulateServerStatus('done')
expect(mockTask.onComplete).toHaveBeenCalled()
})
it('should handle tasks without onComplete callback', async () => {
const queue = useManagerQueue()
const mockTask = { task: vi.fn().mockResolvedValue('result') }
queue.enqueueTask(mockTask)
// Start the task
await simulateServerStatus('done')
expect(mockTask.task).toHaveBeenCalled()
// Simulate task completion
await mockTask.task.mock.results[0].value
// Simulate server cycle
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
// Should not throw errors even without onComplete
expect(queue.allTasksDone.value).toBe(true)
})
it('should process multiple tasks in sequence', async () => {
const queue = useManagerQueue()
const mockTask1 = createMockTask('result1')
const mockTask2 = createMockTask('result2')
// Add tasks to the queue
queue.enqueueTask(mockTask1)
queue.enqueueTask(mockTask2)
expect(queue.queueLength.value).toBe(2)
// Process first task
await simulateServerStatus('done')
expect(mockTask1.task).toHaveBeenCalled()
expect(queue.queueLength.value).toBe(1)
// Complete first task
await mockTask1.task.mock.results[0].value
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
expect(mockTask1.onComplete).toHaveBeenCalled()
// Process second task
expect(mockTask2.task).toHaveBeenCalled()
expect(queue.queueLength.value).toBe(0)
// Complete second task
await mockTask2.task.mock.results[0].value
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
expect(mockTask2.onComplete).toHaveBeenCalled()
// Queue should be empty and all tasks done
expect(queue.queueLength.value).toBe(0)
expect(queue.allTasksDone.value).toBe(true)
})
it('should handle task that returns rejected promise', async () => {
const queue = useManagerQueue()
const mockTask = {
task: vi.fn().mockRejectedValue(new Error('Task failed')),
onComplete: vi.fn()
}
queue.enqueueTask(mockTask)
// Start the task
await simulateServerStatus('done')
expect(mockTask.task).toHaveBeenCalled()
// Let the promise rejection happen
try {
await mockTask.task()
} catch (e) {
// Ignore the error
}
// Simulate server cycle
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
// onComplete should still be called for failed tasks
expect(mockTask.onComplete).toHaveBeenCalled()
})
it('should handle multiple multiple tasks enqueued at once while server busy', async () => {
const queue = useManagerQueue()
const mockTask1 = createMockTask()
const mockTask2 = createMockTask()
const mockTask3 = createMockTask()
// Three tasks enqueued at once
await simulateServerStatus('in_progress')
await Promise.all([
queue.enqueueTask(mockTask1),
queue.enqueueTask(mockTask2),
queue.enqueueTask(mockTask3)
])
// Task 1
await simulateServerStatus('done')
expect(mockTask1.task).toHaveBeenCalled()
// Verify state of onComplete callbacks
expect(mockTask1.onComplete).toHaveBeenCalled()
expect(mockTask2.onComplete).not.toHaveBeenCalled()
expect(mockTask3.onComplete).not.toHaveBeenCalled()
// Verify state of queue
expect(queue.queueLength.value).toBe(2)
expect(queue.allTasksDone.value).toBe(false)
// Task 2
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
expect(mockTask2.task).toHaveBeenCalled()
// Verify state of onComplete callbacks
expect(mockTask2.onComplete).toHaveBeenCalled()
expect(mockTask3.onComplete).not.toHaveBeenCalled()
// Verify state of queue
expect(queue.queueLength.value).toBe(1)
expect(queue.allTasksDone.value).toBe(false)
// Task 3
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
// Verify state of onComplete callbacks
expect(mockTask3.task).toHaveBeenCalled()
expect(mockTask3.onComplete).toHaveBeenCalled()
// Verify state of queue
expect(queue.queueLength.value).toBe(0)
expect(queue.allTasksDone.value).toBe(true)
})
it('should handle adding tasks while processing is in progress', async () => {
const queue = useManagerQueue()
const mockTask1 = createMockTask()
const mockTask2 = createMockTask()
// Add first task and start processing
queue.enqueueTask(mockTask1)
await simulateServerStatus('done')
expect(mockTask1.task).toHaveBeenCalled()
// Add second task while first is processing
queue.enqueueTask(mockTask2)
expect(queue.queueLength.value).toBe(1)
// Complete first task
await mockTask1.task.mock.results[0].value
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
// Second task should now be processed
expect(mockTask2.task).toHaveBeenCalled()
})
it('should handle server status changes without tasks in queue', async () => {
const queue = useManagerQueue()
// Cycle server status without any tasks
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
await simulateServerStatus('in_progress')
await simulateServerStatus('done')
// Should not cause any errors
expect(queue.allTasksDone.value).toBe(true)
})
})
})

View File

@@ -5,7 +5,7 @@ import { LinkConnector } from '@/lib/litegraph/src/litegraph'
import { MovingOutputLink } from '@/lib/litegraph/src/litegraph'
import { ToOutputRenderLink } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { NodeInputSlot } from '@/lib/litegraph/src/litegraph'
import type { NodeInputSlot } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from '../subgraph/fixtures/subgraphHelpers'

View File

@@ -1,6 +1,6 @@
import { describe } from 'vitest'
import { LGraph, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { test } from './fixtures/testExtensions'
@@ -128,6 +128,54 @@ describe('Floating Links / Reroutes', () => {
})
})
describe('Graph Clearing and Callbacks', () => {
test('clear() calls both node.onRemoved() and graph.onNodeRemoved()', ({
expect
}) => {
const graph = new LGraph()
// Create test nodes with onRemoved callbacks
const node1 = new LGraphNode('TestNode1')
const node2 = new LGraphNode('TestNode2')
// Add nodes to graph
graph.add(node1)
graph.add(node2)
// Track callback invocations
const nodeRemovedCallbacks = new Set<string>()
const graphRemovedCallbacks = new Set<string>()
// Set up node.onRemoved() callbacks
node1.onRemoved = () => {
nodeRemovedCallbacks.add(String(node1.id))
}
node2.onRemoved = () => {
nodeRemovedCallbacks.add(String(node2.id))
}
// Set up graph.onNodeRemoved() callback
graph.onNodeRemoved = (node) => {
graphRemovedCallbacks.add(String(node.id))
}
// Verify nodes are in graph before clearing
expect(graph.nodes.length).toBe(2)
// Clear the graph
graph.clear()
// Verify both types of callbacks were called
expect(nodeRemovedCallbacks).toContain(String(node1.id))
expect(nodeRemovedCallbacks).toContain(String(node2.id))
expect(graphRemovedCallbacks).toContain(String(node1.id))
expect(graphRemovedCallbacks).toContain(String(node2.id))
// Verify nodes were actually removed
expect(graph.nodes.length).toBe(0)
})
})
describe('Legacy LGraph Compatibility Layer', () => {
test('can be extended via prototype', ({ expect, minimalGraph }) => {
// @ts-expect-error Should always be an error.

View File

@@ -621,39 +621,10 @@ describe('LGraphNode', () => {
expect(node.getInputSlotPos(inputSlot)).toEqual([expectedX, expectedY])
delete (node.constructor as any).slot_start_y
})
})
describe('getInputPos', () => {
test('should call getInputSlotPos with the correct input slot from inputs array', () => {
const input0: INodeInputSlot = {
name: 'in0',
type: 'string',
link: null,
boundingRect: new Float32Array([0, 0, 0, 0])
}
const input1: INodeInputSlot = {
name: 'in1',
type: 'number',
link: null,
boundingRect: new Float32Array([0, 0, 0, 0]),
pos: [5, 45]
}
node.inputs = [input0, input1]
const spy = vi.spyOn(node, 'getInputSlotPos')
node.getInputPos(1)
expect(spy).toHaveBeenCalledWith(input1)
const expectedPos: Point = [100 + 5, 200 + 45]
expect(node.getInputPos(1)).toEqual(expectedPos)
spy.mockClear()
node.getInputPos(0)
expect(spy).toHaveBeenCalledWith(input0)
const slotIndex = 0
const nodeOffsetY = (node.constructor as any).slot_start_y || 0
const expectedDefaultY =
200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
const expectedDefaultX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5
expect(node.getInputPos(0)).toEqual([expectedDefaultX, expectedDefaultY])
spy.mockRestore()
test('should not overwrite onMouseDown prototype', () => {
expect(Object.prototype.hasOwnProperty.call(node, 'onMouseDown')).toEqual(
false
)
})
})
})

View File

@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphButton } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
describe('LGraphNode Title Buttons', () => {

View File

@@ -1,13 +1,12 @@
// TODO: Fix these tests after migration
import { afterEach, describe, expect, vi } from 'vitest'
import type { LGraph, Reroute } from '@/lib/litegraph/src/litegraph'
import {
type CanvasPointerEvent,
LGraph,
LGraphNode,
LLink,
LinkConnector,
Reroute,
type RerouteId
} from '@/lib/litegraph/src/litegraph'

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import { INodeInputSlot, INodeOutputSlot } from '@/lib/litegraph/src/litegraph'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/litegraph'
import {
inputAsSerialisable,
outputAsSerialisable

View File

@@ -62,6 +62,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -133,6 +134,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -205,6 +207,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,

View File

@@ -102,16 +102,7 @@ LiteGraphGlobal {
"Reroute": [Function],
"SPLINE_LINK": 2,
"STRAIGHT_LINK": 0,
"SlotDirection": {
"1": "Up",
"2": "Down",
"3": "Left",
"4": "Right",
"Down": 2,
"Left": 3,
"Right": 4,
"Up": 1,
},
"SlotDirection": {},
"SlotShape": {
"1": "Box",
"3": "Circle",
@@ -164,9 +155,11 @@ LiteGraphGlobal {
"do_add_triggers_slots": false,
"highlight_selected_group": true,
"isInsideRectangle": [Function],
"leftMouseClickBehavior": "panning",
"macGesturesRequireMac": true,
"macTrackpadGestures": false,
"middle_click_slot_add_default_node": false,
"mouseWheelScroll": "panning",
"node_box_coloured_by_mode": false,
"node_box_coloured_when_on": false,
"node_images_path": "",
@@ -199,5 +192,6 @@ LiteGraphGlobal {
"truncateWidgetValuesFirst": false,
"use_uuids": false,
"uuidv4": [Function],
"vueNodesMode": false,
}
`;

View File

@@ -7,10 +7,10 @@ import type {
SerialisableGraph
} from '@/lib/litegraph/src/types/serialisation'
import floatingBranch from './assets/floatingBranch.json'
import floatingLink from './assets/floatingLink.json'
import linkedNodes from './assets/linkedNodes.json'
import reroutesComplex from './assets/reroutesComplex.json'
import floatingBranch from './assets/floatingBranch.json' with { type: 'json' }
import floatingLink from './assets/floatingLink.json' with { type: 'json' }
import linkedNodes from './assets/linkedNodes.json' with { type: 'json' }
import reroutesComplex from './assets/reroutesComplex.json' with { type: 'json' }
import {
basicSerialisableGraph,
minimalSerialisableGraph,

View File

@@ -1,9 +1,9 @@
// TODO: Fix these tests after migration
import { assert, describe, expect, it } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
ISlotType,
LGraph,
type ISlotType,
LGraphGroup,
LGraphNode,
LiteGraph

View File

@@ -2,7 +2,7 @@
import { describe, expect, it, vi } from 'vitest'
import { LGraphButton } from '@/lib/litegraph/src/litegraph'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,

View File

@@ -5,8 +5,8 @@ import { LinkConnector } from '@/lib/litegraph/src/litegraph'
import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/litegraph'
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, type LinkNetwork } from '@/lib/litegraph/src/litegraph'
import { NodeInputSlot } from '@/lib/litegraph/src/litegraph'
import { NodeOutputSlot } from '@/lib/litegraph/src/litegraph'
import type { NodeInputSlot } from '@/lib/litegraph/src/litegraph'
import type { NodeOutputSlot } from '@/lib/litegraph/src/litegraph'
import {
isSubgraphInput,
isSubgraphOutput

View File

@@ -1,8 +1,8 @@
// TODO: Fix these tests after migration
import { describe, expect, it } from 'vitest'
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
import type { ISlotType, Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { TWidgetType } from '@/lib/litegraph/src/litegraph'
import { BaseWidget } from '@/lib/litegraph/src/litegraph'

View File

@@ -5,8 +5,9 @@
* in their test files. Each fixture provides a clean, pre-configured subgraph
* setup for different testing scenarios.
*/
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { test } from '../../core/fixtures/testExtensions'
import {
@@ -16,7 +17,7 @@ import {
createTestSubgraphNode
} from './subgraphHelpers'
export interface SubgraphFixtures {
interface SubgraphFixtures {
/** A minimal subgraph with no inputs, outputs, or nodes */
emptySubgraph: Subgraph
@@ -169,140 +170,3 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
capture.cleanup()
}
})
/**
* Fixtures that test edge cases and error conditions.
* These may leave the system in an invalid state and should be used carefully.
*/
export interface EdgeCaseFixtures {
/** Subgraph with circular references (for testing recursion detection) */
circularSubgraph: {
rootGraph: LGraph
subgraphA: Subgraph
subgraphB: Subgraph
nodeA: SubgraphNode
nodeB: SubgraphNode
}
/** Deeply nested subgraphs approaching the theoretical limit */
deeplyNestedSubgraph: ReturnType<typeof createNestedSubgraphs>
/** Subgraph with maximum inputs and outputs */
maxIOSubgraph: Subgraph
}
/**
* Test with edge case fixtures. Use sparingly and with caution.
* These tests may intentionally create invalid states.
*/
export const edgeCaseTest = subgraphTest.extend<EdgeCaseFixtures>({
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
// eslint-disable-next-line no-empty-pattern
circularSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
const rootGraph = new LGraph()
// Create two subgraphs that will reference each other
const subgraphA = createTestSubgraph({
name: 'Subgraph A',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
const subgraphB = createTestSubgraph({
name: 'Subgraph B',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
// Create instances (this doesn't create circular refs by itself)
const nodeA = createTestSubgraphNode(subgraphA, { pos: [100, 100] })
const nodeB = createTestSubgraphNode(subgraphB, { pos: [300, 100] })
// Add nodes to root graph
rootGraph.add(nodeA)
rootGraph.add(nodeB)
await use({
rootGraph,
subgraphA,
subgraphB,
nodeA,
nodeB
})
},
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
// eslint-disable-next-line no-empty-pattern
deeplyNestedSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
// Create a very deep nesting structure (but not exceeding MAX_NESTED_SUBGRAPHS)
const nested = createNestedSubgraphs({
depth: 50, // Deep but reasonable
nodesPerLevel: 1,
inputsPerSubgraph: 1,
outputsPerSubgraph: 1
})
await use(nested)
},
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
// eslint-disable-next-line no-empty-pattern
maxIOSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
// Create a subgraph with many inputs and outputs
const inputs = Array.from({ length: 20 }, (_, i) => ({
name: `input_${i}`,
type: i % 2 === 0 ? 'number' : ('string' as const)
}))
const outputs = Array.from({ length: 20 }, (_, i) => ({
name: `output_${i}`,
type: i % 2 === 0 ? 'number' : ('string' as const)
}))
const subgraph = createTestSubgraph({
name: 'Max IO Subgraph',
inputs,
outputs,
nodeCount: 10
})
await use(subgraph)
}
})
/**
* Helper to verify fixture integrity.
* Use this in tests to ensure fixtures are properly set up.
*/
export function verifyFixtureIntegrity<T extends Record<string, unknown>>(
fixture: T,
expectedProperties: (keyof T)[]
): void {
for (const prop of expectedProperties) {
if (!(prop in fixture)) {
throw new Error(`Fixture missing required property: ${String(prop)}`)
}
if (fixture[prop] === undefined || fixture[prop] === null) {
throw new Error(`Fixture property ${String(prop)} is null or undefined`)
}
}
}
/**
* Creates a snapshot-friendly representation of a subgraph for testing.
* Useful for serialization tests and regression detection.
*/
export function createSubgraphSnapshot(subgraph: Subgraph) {
return {
id: subgraph.id,
name: subgraph.name,
inputCount: subgraph.inputs.length,
outputCount: subgraph.outputs.length,
nodeCount: subgraph.nodes.length,
linkCount: subgraph.links.size,
inputs: subgraph.inputs.map((i) => ({ name: i.name, type: i.type })),
outputs: subgraph.outputs.map((o) => ({ name: o.name, type: o.type })),
hasInputNode: !!subgraph.inputNode,
hasOutputNode: !!subgraph.outputNode
}
}

View File

@@ -17,7 +17,7 @@ import type {
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
export interface TestSubgraphOptions {
interface TestSubgraphOptions {
id?: UUID
name?: string
nodeCount?: number
@@ -27,20 +27,20 @@ export interface TestSubgraphOptions {
outputs?: Array<{ name: string; type: ISlotType }>
}
export interface TestSubgraphNodeOptions {
interface TestSubgraphNodeOptions {
id?: NodeId
pos?: [number, number]
size?: [number, number]
}
export interface NestedSubgraphOptions {
interface NestedSubgraphOptions {
depth?: number
nodesPerLevel?: number
inputsPerSubgraph?: number
outputsPerSubgraph?: number
}
export interface SubgraphStructureExpectation {
interface SubgraphStructureExpectation {
inputCount?: number
outputCount?: number
nodeCount?: number
@@ -49,7 +49,7 @@ export interface SubgraphStructureExpectation {
hasOutputNode?: boolean
}
export interface CapturedEvent<T = unknown> {
interface CapturedEvent<T = unknown> {
type: string
detail: T
timestamp: number
@@ -382,76 +382,6 @@ export function createTestSubgraphData(
}
}
/**
* Creates a complex subgraph with multiple nodes and connections.
* Useful for testing realistic scenarios.
* @param nodeCount Number of internal nodes to create
* @returns Complex subgraph data structure
*/
export function createComplexSubgraphData(
nodeCount: number = 5
): ExportedSubgraph {
const nodes = []
const links: Record<
string,
{
id: number
origin_id: number
origin_slot: number
target_id: number
target_slot: number
type: string
}
> = {}
// Create internal nodes
for (let i = 0; i < nodeCount; i++) {
nodes.push({
id: i + 1, // Start from 1 to avoid conflicts with IO nodes
type: 'basic/test',
pos: [100 + i * 150, 200],
size: [120, 60],
inputs: [{ name: 'in', type: '*', link: null }],
outputs: [{ name: 'out', type: '*', links: [] }],
properties: { value: i },
flags: {},
mode: 0
})
}
// Create some internal links
for (let i = 0; i < nodeCount - 1; i++) {
const linkId = i + 1
links[linkId] = {
id: linkId,
origin_id: i + 1,
origin_slot: 0,
target_id: i + 2,
target_slot: 0,
type: '*'
}
}
return createTestSubgraphData({
// @ts-expect-error TODO: Fix after merge - nodes parameter type
nodes,
// @ts-expect-error TODO: Fix after merge - links parameter type
links,
inputs: [
// @ts-expect-error TODO: Fix after merge - input object type
{ name: 'input1', type: 'number', pos: [0, 0] },
// @ts-expect-error TODO: Fix after merge - input object type
{ name: 'input2', type: 'string', pos: [0, 1] }
],
outputs: [
// @ts-expect-error TODO: Fix after merge - output object type
{ name: 'output1', type: 'number', pos: [0, 0] },
// @ts-expect-error TODO: Fix after merge - output object type
{ name: 'output2', type: 'string', pos: [0, 1] }
]
})
}
/**
* Creates an event capture system for testing event sequences.
* @param eventTarget The event target to monitor
@@ -492,40 +422,3 @@ export function createEventCapture<T = unknown>(
capturedEvents.filter((e) => e.type === type)
}
}
/**
* Utility to log subgraph structure for debugging tests.
* @param subgraph The subgraph to inspect
* @param label Optional label for the log output
*/
export function logSubgraphStructure(
subgraph: Subgraph,
label: string = 'Subgraph'
): void {
console.log(`\n=== ${label} Structure ===`)
console.log(`Name: ${subgraph.name}`)
console.log(`ID: ${subgraph.id}`)
console.log(`Inputs: ${subgraph.inputs.length}`)
console.log(`Outputs: ${subgraph.outputs.length}`)
console.log(`Nodes: ${subgraph.nodes.length}`)
console.log(`Links: ${subgraph.links.size}`)
if (subgraph.inputs.length > 0) {
console.log(
'Input details:',
subgraph.inputs.map((i) => ({ name: i.name, type: i.type }))
)
}
if (subgraph.outputs.length > 0) {
console.log(
'Output details:',
subgraph.outputs.map((o) => ({ name: o.name, type: o.type }))
)
}
console.log('========================\n')
}
// Re-export expect from vitest for convenience
export { expect } from 'vitest'

View File

@@ -0,0 +1,174 @@
import { describe, expect, it, vi } from 'vitest'
import { type ComputedRef, computed } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { NodeLayout } from '@/renderer/core/layout/types'
import { MinimapDataSourceFactory } from '@/renderer/extensions/minimap/data/MinimapDataSourceFactory'
// Mock layoutStore
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
getAllNodes: vi.fn()
}
}))
// Helper to create mock links that satisfy LGraph['links'] type
function createMockLinks(): LGraph['links'] {
const map = new Map<number, LLink>()
return Object.assign(map, {}) as LGraph['links']
}
describe('MinimapDataSource', () => {
describe('MinimapDataSourceFactory', () => {
it('should create LayoutStoreDataSource when LayoutStore has data', () => {
// Arrange
const mockNodes = new Map<string, NodeLayout>([
[
'node1',
{
id: 'node1',
position: { x: 0, y: 0 },
size: { width: 100, height: 50 },
zIndex: 0,
visible: true,
bounds: { x: 0, y: 0, width: 100, height: 50 }
}
]
])
// Create a computed ref that returns the map
const computedNodes: ComputedRef<ReadonlyMap<string, NodeLayout>> =
computed(() => mockNodes)
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedNodes)
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
_nodes: [],
_groups: [],
links: createMockLinks()
}
// Act
const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph)
// Assert
expect(dataSource).toBeDefined()
expect(dataSource.hasData()).toBe(true)
expect(dataSource.getNodeCount()).toBe(1)
})
it('should create LiteGraphDataSource when LayoutStore is empty', () => {
// Arrange
const emptyMap = new Map<string, NodeLayout>()
const computedEmpty: ComputedRef<ReadonlyMap<string, NodeLayout>> =
computed(() => emptyMap)
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty)
const mockNode: Pick<
LGraphNode,
'id' | 'pos' | 'size' | 'bgcolor' | 'mode' | 'has_errors' | 'outputs'
> = {
id: 'node1' as NodeId,
pos: [0, 0],
size: [100, 50],
bgcolor: '#fff',
mode: 0,
has_errors: false,
outputs: []
}
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
_nodes: [mockNode as LGraphNode],
_groups: [],
links: createMockLinks()
}
// Act
const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph)
// Assert
expect(dataSource).toBeDefined()
expect(dataSource.hasData()).toBe(true)
expect(dataSource.getNodeCount()).toBe(1)
const nodes = dataSource.getNodes()
expect(nodes).toHaveLength(1)
expect(nodes[0]).toMatchObject({
id: 'node1',
x: 0,
y: 0,
width: 100,
height: 50
})
})
it('should handle empty graph correctly', () => {
// Arrange
const emptyMap = new Map<string, NodeLayout>()
const computedEmpty: ComputedRef<ReadonlyMap<string, NodeLayout>> =
computed(() => emptyMap)
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty)
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
_nodes: [],
_groups: [],
links: createMockLinks()
}
// Act
const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph)
// Assert
expect(dataSource.hasData()).toBe(false)
expect(dataSource.getNodeCount()).toBe(0)
expect(dataSource.getNodes()).toEqual([])
expect(dataSource.getLinks()).toEqual([])
expect(dataSource.getGroups()).toEqual([])
})
})
describe('Bounds calculation', () => {
it('should calculate correct bounds from nodes', () => {
// Arrange
const emptyMap = new Map<string, NodeLayout>()
const computedEmpty: ComputedRef<ReadonlyMap<string, NodeLayout>> =
computed(() => emptyMap)
vi.mocked(layoutStore.getAllNodes).mockReturnValue(computedEmpty)
const mockNode1: Pick<LGraphNode, 'id' | 'pos' | 'size' | 'outputs'> = {
id: 'node1' as NodeId,
pos: [0, 0],
size: [100, 50],
outputs: []
}
const mockNode2: Pick<LGraphNode, 'id' | 'pos' | 'size' | 'outputs'> = {
id: 'node2' as NodeId,
pos: [200, 100],
size: [150, 75],
outputs: []
}
const mockGraph: Pick<LGraph, '_nodes' | '_groups' | 'links'> = {
_nodes: [mockNode1 as LGraphNode, mockNode2 as LGraphNode],
_groups: [],
links: createMockLinks()
}
// Act
const dataSource = MinimapDataSourceFactory.create(mockGraph as LGraph)
const bounds = dataSource.getBounds()
// Assert
expect(bounds).toEqual({
minX: 0,
minY: 0,
maxX: 350,
maxY: 175,
width: 350,
height: 175
})
})
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { NodeSearchService } from '@/services/nodeSearchService'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'

View File

@@ -0,0 +1,483 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
// Mock canvas context for testing
const createMockCanvasContext = () => ({
ds: {
offset: [0, 0] as [number, number],
scale: 1
}
})
// Skip this entire suite on CI to avoid flaky performance timing
const isCI = Boolean(process.env.CI)
const describeIfNotCI = isCI ? describe.skip : describe
describeIfNotCI.skip('Transform Performance', () => {
let transformState: ReturnType<typeof useTransformState>
let mockCanvas: any
beforeEach(() => {
transformState = useTransformState()
mockCanvas = createMockCanvasContext()
})
describe('coordinate conversion performance', () => {
it('should handle large batches of coordinate conversions efficiently', () => {
// Set up a realistic transform state
mockCanvas.ds.offset = [500, 300]
mockCanvas.ds.scale = 1.5
transformState.syncWithCanvas(mockCanvas)
const conversionCount = 10000
const points = Array.from({ length: conversionCount }, () => ({
x: Math.random() * 5000,
y: Math.random() * 3000
}))
// Benchmark canvas to screen conversions
const canvasToScreenStart = performance.now()
const screenPoints = points.map((point) =>
transformState.canvasToScreen(point)
)
const canvasToScreenTime = performance.now() - canvasToScreenStart
// Benchmark screen to canvas conversions
const screenToCanvasStart = performance.now()
const backToCanvas = screenPoints.map((point) =>
transformState.screenToCanvas(point)
)
const screenToCanvasTime = performance.now() - screenToCanvasStart
// Performance expectations
expect(canvasToScreenTime).toBeLessThan(20) // 10k conversions in under 20ms
expect(screenToCanvasTime).toBeLessThan(20) // 10k conversions in under 20ms
// Verify accuracy of round-trip conversion
const maxError = points.reduce((max, original, i) => {
const converted = backToCanvas[i]
const errorX = Math.abs(original.x - converted.x)
const errorY = Math.abs(original.y - converted.y)
return Math.max(max, errorX, errorY)
}, 0)
expect(maxError).toBeLessThan(0.001) // Sub-pixel accuracy
})
it('should maintain performance across different zoom levels', () => {
const zoomLevels = [0.1, 0.5, 1.0, 2.0, 5.0, 10.0]
const conversionCount = 1000
const testPoints = Array.from({ length: conversionCount }, () => ({
x: Math.random() * 2000,
y: Math.random() * 1500
}))
const performanceResults: number[] = []
zoomLevels.forEach((scale) => {
mockCanvas.ds.scale = scale
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
testPoints.forEach((point) => {
const screen = transformState.canvasToScreen(point)
transformState.screenToCanvas(screen)
})
const duration = performance.now() - startTime
performanceResults.push(duration)
})
// Performance should be consistent across zoom levels
const maxTime = Math.max(...performanceResults)
const minTime = Math.min(...performanceResults)
const variance = (maxTime - minTime) / minTime
expect(maxTime).toBeLessThan(20) // All zoom levels under 20ms
expect(variance).toBeLessThan(3.0) // Less than 300% variance between zoom levels
})
it('should handle extreme coordinate values efficiently', () => {
// Test with very large coordinate values
const extremePoints = [
{ x: -100000, y: -100000 },
{ x: 100000, y: 100000 },
{ x: 0, y: 0 },
{ x: -50000, y: 50000 },
{ x: 1e6, y: -1e6 }
]
// Test at extreme zoom levels
const extremeScales = [0.001, 1000]
extremeScales.forEach((scale) => {
mockCanvas.ds.scale = scale
mockCanvas.ds.offset = [1000, 500]
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
// Convert each point 100 times
extremePoints.forEach((point) => {
for (let i = 0; i < 100; i++) {
const screen = transformState.canvasToScreen(point)
transformState.screenToCanvas(screen)
}
})
const duration = performance.now() - startTime
expect(duration).toBeLessThan(5) // Should handle extremes efficiently
expect(
Number.isFinite(transformState.canvasToScreen(extremePoints[0]).x)
).toBe(true)
expect(
Number.isFinite(transformState.canvasToScreen(extremePoints[0]).y)
).toBe(true)
})
})
})
describe('viewport culling performance', () => {
it('should efficiently determine node visibility for large numbers of nodes', () => {
// Set up realistic viewport
const viewport = { width: 1920, height: 1080 }
// Generate many node positions
const nodeCount = 1000
const nodes = Array.from({ length: nodeCount }, () => ({
pos: [Math.random() * 10000, Math.random() * 6000] as ArrayLike<number>,
size: [
150 + Math.random() * 100,
100 + Math.random() * 50
] as ArrayLike<number>
}))
// Test at different zoom levels and positions
const testConfigs = [
{ scale: 0.5, offset: [0, 0] },
{ scale: 1.0, offset: [2000, 1000] },
{ scale: 2.0, offset: [-1000, -500] }
]
testConfigs.forEach((config) => {
mockCanvas.ds.scale = config.scale
mockCanvas.ds.offset = config.offset
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
// Test viewport culling for all nodes
const visibleNodes = nodes.filter((node) =>
transformState.isNodeInViewport(node.pos, node.size, viewport)
)
const cullTime = performance.now() - startTime
expect(cullTime).toBeLessThan(10) // 1000 nodes culled in under 10ms
expect(visibleNodes.length).toBeLessThan(nodeCount) // Some culling should occur
expect(visibleNodes.length).toBeGreaterThanOrEqual(0) // Sanity check
})
})
it('should optimize culling with adaptive margins', () => {
const viewport = { width: 1280, height: 720 }
const testNode = {
pos: [1300, 100] as ArrayLike<number>, // Just outside viewport
size: [200, 100] as ArrayLike<number>
}
// Test margin adaptation at different zoom levels
const zoomTests = [
{ scale: 0.05, expectedVisible: true }, // Low zoom, larger margin
{ scale: 1.0, expectedVisible: true }, // Normal zoom, standard margin
{ scale: 4.0, expectedVisible: false } // High zoom, tighter margin
]
const marginTests: boolean[] = []
const timings: number[] = []
zoomTests.forEach((test) => {
mockCanvas.ds.scale = test.scale
mockCanvas.ds.offset = [0, 0]
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
const isVisible = transformState.isNodeInViewport(
testNode.pos,
testNode.size,
viewport,
0.2 // 20% margin
)
const duration = performance.now() - startTime
marginTests.push(isVisible)
timings.push(duration)
})
// All culling operations should be very fast
timings.forEach((time) => {
expect(time).toBeLessThan(0.1) // Individual culling under 0.1ms
})
// Verify adaptive behavior (margins should work as expected)
expect(marginTests[0]).toBe(zoomTests[0].expectedVisible)
expect(marginTests[2]).toBe(zoomTests[2].expectedVisible)
})
it('should handle size-based culling efficiently', () => {
// Test nodes of various sizes
const nodeSizes = [
[1, 1], // Tiny node
[5, 5], // Small node
[50, 50], // Medium node
[200, 100], // Large node
[500, 300] // Very large node
]
const viewport = { width: 1920, height: 1080 }
// Position all nodes in viewport center
const centerPos = [960, 540] as ArrayLike<number>
nodeSizes.forEach((size) => {
// Test at very low zoom where size culling should activate
mockCanvas.ds.scale = 0.01 // Very low zoom
transformState.syncWithCanvas(mockCanvas)
const startTime = performance.now()
const isVisible = transformState.isNodeInViewport(
centerPos,
size as ArrayLike<number>,
viewport
)
const cullTime = performance.now() - startTime
expect(cullTime).toBeLessThan(0.1) // Size culling under 0.1ms
// At 0.01 zoom, nodes need to be 400+ pixels to show as 4+ screen pixels
const screenSize = Math.max(size[0], size[1]) * 0.01
if (screenSize < 4) {
expect(isVisible).toBe(false)
} else {
expect(isVisible).toBe(true)
}
})
})
})
describe('transform state synchronization', () => {
it('should efficiently sync with canvas state changes', () => {
const syncCount = 1000
const transformUpdates = Array.from({ length: syncCount }, (_, i) => ({
offset: [Math.sin(i * 0.1) * 1000, Math.cos(i * 0.1) * 500],
scale: 0.5 + Math.sin(i * 0.05) * 0.4 // Scale between 0.1 and 0.9
}))
const startTime = performance.now()
transformUpdates.forEach((update) => {
mockCanvas.ds.offset = update.offset
mockCanvas.ds.scale = update.scale
transformState.syncWithCanvas(mockCanvas)
})
const syncTime = performance.now() - startTime
expect(syncTime).toBeLessThan(15) // 1000 syncs in under 15ms
// Verify final state is correct
const lastUpdate = transformUpdates[transformUpdates.length - 1]
expect(transformState.camera.x).toBe(lastUpdate.offset[0])
expect(transformState.camera.y).toBe(lastUpdate.offset[1])
expect(transformState.camera.z).toBe(lastUpdate.scale)
})
it('should generate CSS transform strings efficiently', () => {
const transformCount = 10000
// Set up varying transform states
const transforms = Array.from({ length: transformCount }, (_, i) => {
mockCanvas.ds.offset = [i * 10, i * 5]
mockCanvas.ds.scale = 0.5 + (i % 100) / 100
transformState.syncWithCanvas(mockCanvas)
return transformState.transformStyle.value
})
const startTime = performance.now()
// Access transform styles (triggers computed property)
transforms.forEach((style) => {
expect(style.transform).toContain('scale(')
expect(style.transform).toContain('translate(')
expect(style.transformOrigin).toBe('0 0')
})
const accessTime = performance.now() - startTime
expect(accessTime).toBeLessThan(200) // 10k style accesses in under 200ms
})
})
describe('bounds calculation performance', () => {
it('should calculate node screen bounds efficiently', () => {
// Set up realistic transform
mockCanvas.ds.offset = [200, 100]
mockCanvas.ds.scale = 1.5
transformState.syncWithCanvas(mockCanvas)
const nodeCount = 1000
const nodes = Array.from({ length: nodeCount }, () => ({
pos: [Math.random() * 5000, Math.random() * 3000] as ArrayLike<number>,
size: [
100 + Math.random() * 200,
80 + Math.random() * 120
] as ArrayLike<number>
}))
const startTime = performance.now()
const bounds = nodes.map((node) =>
transformState.getNodeScreenBounds(node.pos, node.size)
)
const calcTime = performance.now() - startTime
expect(calcTime).toBeLessThan(15) // 1000 bounds calculations in under 15ms
expect(bounds).toHaveLength(nodeCount)
// Verify bounds are reasonable
bounds.forEach((bound) => {
expect(bound.width).toBeGreaterThan(0)
expect(bound.height).toBeGreaterThan(0)
expect(Number.isFinite(bound.x)).toBe(true)
expect(Number.isFinite(bound.y)).toBe(true)
})
})
it('should calculate viewport bounds efficiently', () => {
const viewportSizes = [
{ width: 800, height: 600 },
{ width: 1920, height: 1080 },
{ width: 3840, height: 2160 },
{ width: 1280, height: 720 }
]
const margins = [0, 0.1, 0.2, 0.5]
const combinations = viewportSizes.flatMap((viewport) =>
margins.map((margin) => ({ viewport, margin }))
)
const startTime = performance.now()
const allBounds = combinations.map(({ viewport, margin }) => {
mockCanvas.ds.offset = [Math.random() * 1000, Math.random() * 500]
mockCanvas.ds.scale = 0.5 + Math.random() * 2
transformState.syncWithCanvas(mockCanvas)
return transformState.getViewportBounds(viewport, margin)
})
const calcTime = performance.now() - startTime
expect(calcTime).toBeLessThan(5) // All viewport calculations in under 5ms
expect(allBounds).toHaveLength(combinations.length)
// Verify bounds are reasonable
allBounds.forEach((bounds) => {
expect(bounds.width).toBeGreaterThan(0)
expect(bounds.height).toBeGreaterThan(0)
expect(Number.isFinite(bounds.x)).toBe(true)
expect(Number.isFinite(bounds.y)).toBe(true)
})
})
})
describe('real-world performance scenarios', () => {
it('should handle smooth panning performance', () => {
// Simulate smooth 60fps panning for 2 seconds
const frameCount = 120 // 2 seconds at 60fps
const panDistance = 2000 // Pan 2000 pixels
const frames: number[] = []
for (let frame = 0; frame < frameCount; frame++) {
const progress = frame / (frameCount - 1)
const x = progress * panDistance
const y = Math.sin(progress * Math.PI * 2) * 200 // Slight vertical wave
mockCanvas.ds.offset = [x, y]
const frameStart = performance.now()
// Typical operations during panning
transformState.syncWithCanvas(mockCanvas)
const style = transformState.transformStyle.value // Access transform style
expect(style.transform).toContain('translate') // Verify style is valid
// Simulate some coordinate conversions (mouse tracking, etc.)
for (let i = 0; i < 5; i++) {
const screen = transformState.canvasToScreen({
x: x + i * 100,
y: y + i * 50
})
transformState.screenToCanvas(screen)
}
const frameTime = performance.now() - frameStart
frames.push(frameTime)
// Each frame should be well under 16.67ms for 60fps
expect(frameTime).toBeLessThan(1) // Conservative: under 1ms per frame
}
const totalTime = frames.reduce((sum, time) => sum + time, 0)
const avgFrameTime = totalTime / frameCount
expect(avgFrameTime).toBeLessThan(0.5) // Average frame time under 0.5ms
expect(totalTime).toBeLessThan(60) // Total panning overhead under 60ms
})
it('should handle zoom performance with viewport updates', () => {
// Simulate smooth zoom from 0.1x to 10x
const zoomSteps = 100
const viewport = { width: 1920, height: 1080 }
const zoomTimes: number[] = []
for (let step = 0; step < zoomSteps; step++) {
const zoomLevel = Math.pow(10, (step / (zoomSteps - 1)) * 2 - 1) // 0.1 to 10
mockCanvas.ds.scale = zoomLevel
const stepStart = performance.now()
// Operations during zoom
transformState.syncWithCanvas(mockCanvas)
// Viewport bounds calculation (for culling)
transformState.getViewportBounds(viewport, 0.2)
// Test a few nodes for visibility
for (let i = 0; i < 10; i++) {
transformState.isNodeInViewport(
[i * 200, i * 150],
[200, 100],
viewport
)
}
const stepTime = performance.now() - stepStart
zoomTimes.push(stepTime)
}
const maxZoomTime = Math.max(...zoomTimes)
const avgZoomTime =
zoomTimes.reduce((sum, time) => sum + time, 0) / zoomSteps
expect(maxZoomTime).toBeLessThan(2) // No zoom step over 2ms
expect(avgZoomTime).toBeLessThan(1) // Average zoom step under 1ms
})
})
})

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModel,
getAssetDescription
} from '@/platform/assets/utils/assetMetadataUtils'
describe('assetMetadataUtils', () => {
const mockAsset: AssetItem = {
id: 'test-id',
name: 'test-model',
asset_hash: 'hash123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z'
}
describe('getAssetDescription', () => {
it('should return string description when present', () => {
const asset = {
...mockAsset,
user_metadata: { description: 'A test model' }
}
expect(getAssetDescription(asset)).toBe('A test model')
})
it('should return null when description is not a string', () => {
const asset = {
...mockAsset,
user_metadata: { description: 123 }
}
expect(getAssetDescription(asset)).toBeNull()
})
it('should return null when no metadata', () => {
expect(getAssetDescription(mockAsset)).toBeNull()
})
})
describe('getAssetBaseModel', () => {
it('should return string base_model when present', () => {
const asset = {
...mockAsset,
user_metadata: { base_model: 'SDXL' }
}
expect(getAssetBaseModel(asset)).toBe('SDXL')
})
it('should return null when base_model is not a string', () => {
const asset = {
...mockAsset,
user_metadata: { base_model: 123 }
}
expect(getAssetBaseModel(asset)).toBeNull()
})
it('should return null when no metadata', () => {
expect(getAssetBaseModel(mockAsset)).toBeNull()
})
})
})

View File

@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
// Mock stores
vi.mock('@/renderer/core/canvas/canvasStore', () => {
const getCanvas = vi.fn()
const setCursorStyle = vi.fn()
return {
useCanvasStore: vi.fn(() => ({
getCanvas,
setCursorStyle
}))
}
})
vi.mock('@/platform/settings/settingStore', () => {
const getFn = vi.fn()
return { useSettingStore: vi.fn(() => ({ get: getFn })) }
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
canvas: {
dispatchEvent: vi.fn()
}
}
}
}))
function createMockLGraphCanvas(read_only = true): LGraphCanvas {
const mockCanvas: Partial<LGraphCanvas> = { read_only }
return mockCanvas as LGraphCanvas
}
function createMockPointerEvent(
buttons: PointerEvent['buttons'] = 1
): PointerEvent {
const mockEvent: Partial<PointerEvent> = {
buttons,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
}
return mockEvent as PointerEvent
}
function createMockWheelEvent(ctrlKey = false, metaKey = false): WheelEvent {
const mockEvent: Partial<WheelEvent> = {
ctrlKey,
metaKey,
preventDefault: vi.fn(),
stopPropagation: vi.fn()
}
return mockEvent as WheelEvent
}
describe('useCanvasInteractions', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('handlePointer', () => {
it('should intercept left mouse events when canvas is read_only to enable space+drag navigation', () => {
const { getCanvas } = useCanvasStore()
const mockCanvas = createMockLGraphCanvas(true)
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent(1) // Left Mouse Button
handlePointer(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should forward middle mouse button events to canvas', () => {
const { getCanvas } = useCanvasStore()
const mockCanvas = createMockLGraphCanvas(false)
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent(4) // Middle mouse button
handlePointer(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should not prevent default when canvas is not in read_only mode and not middle button', () => {
const { getCanvas } = useCanvasStore()
const mockCanvas = createMockLGraphCanvas(false)
vi.mocked(getCanvas).mockReturnValue(mockCanvas)
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent(1)
handlePointer(mockEvent)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})
it('should return early when canvas is null', () => {
const { getCanvas } = useCanvasStore()
vi.mocked(getCanvas).mockReturnValue(null as unknown as LGraphCanvas) // TODO: Fix misaligned types
const { handlePointer } = useCanvasInteractions()
const mockEvent = createMockPointerEvent(1)
handlePointer(mockEvent)
expect(getCanvas).toHaveBeenCalled()
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})
})
describe('handleWheel', () => {
it('should forward ctrl+wheel events to canvas in standard nav mode', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const { handleWheel } = useCanvasInteractions()
// Ctrl key pressed
const mockEvent = createMockWheelEvent(true)
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should forward all wheel events to canvas in legacy nav mode', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('legacy')
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent()
handleWheel(mockEvent)
expect(mockEvent.preventDefault).toHaveBeenCalled()
expect(mockEvent.stopPropagation).toHaveBeenCalled()
})
it('should not prevent default for regular wheel events in standard nav mode', () => {
const { get } = useSettingStore()
vi.mocked(get).mockReturnValue('standard')
const { handleWheel } = useCanvasInteractions()
const mockEvent = createMockWheelEvent()
handleWheel(mockEvent)
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
expect(mockEvent.stopPropagation).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,264 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import {
type LayoutChange,
LayoutSource,
type NodeLayout
} from '@/renderer/core/layout/types'
describe('layoutStore CRDT operations', () => {
beforeEach(() => {
// Clear the store before each test
layoutStore.initializeFromLiteGraph([])
})
// Helper to create test node data
const createTestNode = (id: string): NodeLayout => ({
id,
position: { x: 100, y: 100 },
size: { width: 200, height: 100 },
zIndex: 0,
visible: true,
bounds: { x: 100, y: 100, width: 200, height: 100 }
})
it('should create and retrieve nodes', () => {
const nodeId = 'test-node-1'
const layout = createTestNode(nodeId)
// Create node
layoutStore.setSource(LayoutSource.External)
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Retrieve node
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value).toEqual(layout)
})
it('should move nodes', () => {
const nodeId = 'test-node-2'
const layout = createTestNode(nodeId)
// Create node first
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Move node
const newPosition = { x: 200, y: 300 }
layoutStore.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: newPosition,
previousPosition: layout.position,
timestamp: Date.now(),
source: LayoutSource.Vue,
actor: 'test'
})
// Verify position updated
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.position).toEqual(newPosition)
})
it('should resize nodes', () => {
const nodeId = 'test-node-3'
const layout = createTestNode(nodeId)
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Resize node
const newSize = { width: 300, height: 150 }
layoutStore.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId,
size: newSize,
previousSize: layout.size,
timestamp: Date.now(),
source: LayoutSource.Canvas,
actor: 'test'
})
// Verify size updated
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.size).toEqual(newSize)
})
it('should delete nodes', () => {
const nodeId = 'test-node-4'
const layout = createTestNode(nodeId)
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Delete node
layoutStore.applyOperation({
type: 'deleteNode',
entity: 'node',
nodeId,
previousLayout: layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
// Verify node deleted
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value).toBeNull()
})
it('should handle source and actor tracking', async () => {
const nodeId = 'test-node-5'
const layout = createTestNode(nodeId)
// Set source and actor
layoutStore.setSource(LayoutSource.Vue)
layoutStore.setActor('user-123')
// Track change notifications AFTER setting source/actor
const changes: LayoutChange[] = []
const unsubscribe = layoutStore.onChange((change) => {
changes.push(change)
})
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
// Wait for async notification
await new Promise((resolve) => setTimeout(resolve, 50))
expect(changes.length).toBeGreaterThanOrEqual(1)
const lastChange = changes[changes.length - 1]
expect(lastChange.source).toBe('vue')
expect(lastChange.operation.actor).toBe('user-123')
unsubscribe()
})
it('should query nodes by spatial bounds', () => {
const nodes = [
{ id: 'node-a', position: { x: 0, y: 0 } },
{ id: 'node-b', position: { x: 100, y: 100 } },
{ id: 'node-c', position: { x: 250, y: 250 } }
]
// Create nodes with proper bounds
nodes.forEach(({ id, position }) => {
const layout: NodeLayout = {
...createTestNode(id),
position,
bounds: {
x: position.x,
y: position.y,
width: 200,
height: 100
}
}
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId: id,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
})
// Query nodes in bounds
const nodesInBounds = layoutStore.queryNodesInBounds({
x: 50,
y: 50,
width: 200,
height: 200
})
// node-a: (0,0) to (200,100) - overlaps with query bounds (50,50) to (250,250)
// node-b: (100,100) to (300,200) - overlaps with query bounds
// node-c: (250,250) to (450,350) - touches corner of query bounds
expect(nodesInBounds).toContain('node-a')
expect(nodesInBounds).toContain('node-b')
expect(nodesInBounds).toContain('node-c')
})
it('should maintain operation history', () => {
const nodeId = 'test-node-history'
const layout = createTestNode(nodeId)
const startTime = Date.now()
// Create node
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: startTime,
source: LayoutSource.External,
actor: 'test-actor'
})
// Move node
layoutStore.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: { x: 150, y: 150 },
previousPosition: { x: 100, y: 100 },
timestamp: startTime + 100,
source: LayoutSource.Vue,
actor: 'test-actor'
})
// Get operations by actor
const operations = layoutStore.getOperationsByActor('test-actor')
expect(operations.length).toBeGreaterThanOrEqual(2)
expect(operations[0].type).toBe('createNode')
expect(operations[1].type).toBe('moveNode')
// Get operations since timestamp
const recentOps = layoutStore.getOperationsSince(startTime + 50)
expect(recentOps.length).toBeGreaterThanOrEqual(1)
expect(recentOps[0].type).toBe('moveNode')
})
})

View File

@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import type { NodeLayout } from '@/renderer/core/layout/types'
import {
REROUTE_RADIUS,
boundsIntersect,
calculateBounds,
pointInBounds
} from '@/renderer/core/layout/utils/layoutMath'
describe('layoutMath utils', () => {
describe('pointInBounds', () => {
it('detects inclusion correctly', () => {
const bounds = { x: 10, y: 10, width: 100, height: 50 }
expect(pointInBounds({ x: 10, y: 10 }, bounds)).toBe(true)
expect(pointInBounds({ x: 110, y: 60 }, bounds)).toBe(true)
expect(pointInBounds({ x: 9, y: 10 }, bounds)).toBe(false)
expect(pointInBounds({ x: 111, y: 10 }, bounds)).toBe(false)
expect(pointInBounds({ x: 10, y: 61 }, bounds)).toBe(false)
})
it('works with zero-size bounds', () => {
const zero = { x: 10, y: 20, width: 0, height: 0 }
expect(pointInBounds({ x: 10, y: 20 }, zero)).toBe(true)
expect(pointInBounds({ x: 10, y: 21 }, zero)).toBe(false)
expect(pointInBounds({ x: 9, y: 20 }, zero)).toBe(false)
})
})
describe('boundsIntersect', () => {
it('detects intersection correctly', () => {
const a = { x: 0, y: 0, width: 10, height: 10 }
const b = { x: 5, y: 5, width: 10, height: 10 }
const c = { x: 11, y: 0, width: 5, height: 5 }
expect(boundsIntersect(a, b)).toBe(true)
expect(boundsIntersect(a, c)).toBe(false)
})
it('treats touching edges as intersecting', () => {
const a = { x: 0, y: 0, width: 10, height: 10 }
const d = { x: 10, y: 0, width: 5, height: 5 } // touches at right edge
expect(boundsIntersect(a, d)).toBe(true)
})
})
describe('REROUTE_RADIUS', () => {
it('exports a sensible reroute radius', () => {
expect(REROUTE_RADIUS).toBeGreaterThan(0)
})
})
describe('calculateBounds', () => {
const createTestNode = (
id: string,
x: number,
y: number,
width: number,
height: number
): NodeLayout => ({
id,
position: { x, y },
size: { width, height },
zIndex: 0,
visible: true,
bounds: { x, y, width, height }
})
it('calculates bounds for single node', () => {
const nodes = [createTestNode('1', 10, 20, 100, 50)]
const bounds = calculateBounds(nodes)
expect(bounds).toEqual({
x: 10,
y: 20,
width: 100,
height: 50
})
})
it('calculates combined bounds for multiple nodes', () => {
const nodes = [
createTestNode('1', 0, 0, 50, 50), // Top-left: (0,0) to (50,50)
createTestNode('2', 100, 100, 30, 40), // Bottom-right: (100,100) to (130,140)
createTestNode('3', 25, 75, 20, 10) // Middle: (25,75) to (45,85)
]
const bounds = calculateBounds(nodes)
expect(bounds).toEqual({
x: 0, // leftmost
y: 0, // topmost
width: 130, // rightmost (130) - leftmost (0)
height: 140 // bottommost (140) - topmost (0)
})
})
it('handles nodes with negative positions', () => {
const nodes = [
createTestNode('1', -50, -30, 40, 20), // (-50,-30) to (-10,-10)
createTestNode('2', 10, 15, 25, 35) // (10,15) to (35,50)
]
const bounds = calculateBounds(nodes)
expect(bounds).toEqual({
x: -50,
y: -30,
width: 85, // 35 - (-50)
height: 80 // 50 - (-30)
})
})
it('handles empty array', () => {
const bounds = calculateBounds([])
expect(bounds).toEqual({
x: Infinity,
y: Infinity,
width: -Infinity,
height: -Infinity
})
})
})
})

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { makeLinkSegmentKey } from '@/renderer/core/layout/utils/layoutUtils'
describe('layoutUtils', () => {
describe('makeLinkSegmentKey', () => {
it('creates stable keys for null reroute', () => {
expect(makeLinkSegmentKey(10, null)).toBe('10:final')
expect(makeLinkSegmentKey(42, null)).toBe('42:final')
})
it('creates stable keys for numeric reroute ids', () => {
expect(makeLinkSegmentKey(10, 3)).toBe('10:3')
expect(makeLinkSegmentKey(42, 0)).toBe('42:0')
expect(makeLinkSegmentKey(42, 7)).toBe('42:7')
})
})
})

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import * as Y from 'yjs'
import {
NODE_LAYOUT_DEFAULTS,
type NodeLayoutMap,
yNodeToLayout
} from '@/renderer/core/layout/utils/mappers'
describe('mappers', () => {
it('yNodeToLayout reads from Yjs-attached map', () => {
const layout = {
id: 'node-1',
position: { x: 12, y: 34 },
size: { width: 111, height: 222 },
zIndex: 5,
visible: true,
bounds: { x: 12, y: 34, width: 111, height: 222 }
}
const doc = new Y.Doc()
const ynode = doc.getMap('node') as NodeLayoutMap
ynode.set('id', layout.id)
ynode.set('position', layout.position)
ynode.set('size', layout.size)
ynode.set('zIndex', layout.zIndex)
ynode.set('visible', layout.visible)
ynode.set('bounds', layout.bounds)
const back = yNodeToLayout(ynode)
expect(back).toEqual(layout)
})
it('yNodeToLayout applies defaults for missing fields', () => {
const doc = new Y.Doc()
const ynode = doc.getMap('node') as NodeLayoutMap
// Don't set any fields - they should all use defaults
const back = yNodeToLayout(ynode)
expect(back.id).toBe(NODE_LAYOUT_DEFAULTS.id)
expect(back.position).toEqual(NODE_LAYOUT_DEFAULTS.position)
expect(back.size).toEqual(NODE_LAYOUT_DEFAULTS.size)
expect(back.zIndex).toEqual(NODE_LAYOUT_DEFAULTS.zIndex)
expect(back.visible).toEqual(NODE_LAYOUT_DEFAULTS.visible)
expect(back.bounds).toEqual(NODE_LAYOUT_DEFAULTS.bounds)
})
})

View File

@@ -1,11 +1,11 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useMinimapSettings } from '@/renderer/extensions/minimap/composables/useMinimapSettings'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
vi.mock('@/stores/settingStore')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/workspace/colorPaletteStore')
describe('useMinimapSettings', () => {

View File

@@ -1,12 +1,12 @@
import { useRafFn } from '@vueuse/core'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
vi.mock('@/composables/canvas/useCanvasTransformSync')
vi.mock('@vueuse/core')
vi.mock('@/renderer/core/spatial/boundsCalculator', () => ({
calculateNodeBounds: vi.fn(),
calculateMinimapScale: vi.fn(),
@@ -41,10 +41,10 @@ describe('useMinimapViewport', () => {
]
} as any
vi.mocked(useCanvasTransformSync).mockReturnValue({
startSync: vi.fn(),
stopSync: vi.fn()
} as any)
vi.mocked(useRafFn, { partial: true }).mockReturnValue({
resume: vi.fn(),
pause: vi.fn()
})
})
it('should initialize with default bounds', () => {
@@ -206,10 +206,10 @@ describe('useMinimapViewport', () => {
const startSyncMock = vi.fn()
const stopSyncMock = vi.fn()
vi.mocked(useCanvasTransformSync).mockReturnValue({
startSync: startSyncMock,
stopSync: stopSyncMock
} as any)
vi.mocked(useRafFn, { partial: true }).mockReturnValue({
resume: startSyncMock,
pause: stopSyncMock
})
const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any)

View File

@@ -0,0 +1,277 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import ImagePreview from '@/renderer/extensions/vueNodes/components/ImagePreview.vue'
// Mock downloadFile to avoid DOM errors
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn()
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
editOrMaskImage: 'Edit or mask image',
downloadImage: 'Download image',
removeImage: 'Remove image',
viewImageOfTotal: 'View image {index} of {total}',
imagePreview:
'Image preview - Use arrow keys to navigate between images',
errorLoadingImage: 'Error loading image',
failedToDownloadImage: 'Failed to download image',
calculatingDimensions: 'Calculating dimensions',
imageFailedToLoad: 'Image failed to load',
loading: 'Loading'
}
}
}
})
describe('ImagePreview', () => {
const defaultProps = {
imageUrls: [
'/api/view?filename=test1.png&type=output',
'/api/view?filename=test2.png&type=output'
]
}
const mountImagePreview = (props = {}) => {
return mount(ImagePreview, {
props: { ...defaultProps, ...props },
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
'i-lucide:venetian-mask': true,
'i-lucide:download': true,
'i-lucide:x': true,
'i-lucide:image-off': true,
Skeleton: true
}
}
})
}
it('renders image preview when imageUrls provided', () => {
const wrapper = mountImagePreview()
expect(wrapper.find('.image-preview').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(true)
expect(wrapper.find('img').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
})
it('does not render when no imageUrls provided', () => {
const wrapper = mountImagePreview({ imageUrls: [] })
expect(wrapper.find('.image-preview').exists()).toBe(false)
})
it('displays calculating dimensions text initially', () => {
const wrapper = mountImagePreview()
expect(wrapper.text()).toContain('Calculating dimensions')
})
it('shows navigation dots for multiple images', () => {
const wrapper = mountImagePreview()
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
expect(navigationDots).toHaveLength(2)
})
it('does not show navigation dots for single image', () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
expect(navigationDots).toHaveLength(0)
})
it('shows action buttons on hover', async () => {
const wrapper = mountImagePreview()
// Initially buttons should not be visible
expect(wrapper.find('.actions').exists()).toBe(false)
// Trigger hover
await wrapper.trigger('mouseenter')
await nextTick()
// Action buttons should now be visible
expect(wrapper.find('.actions').exists()).toBe(true)
expect(wrapper.findAll('.action-btn')).toHaveLength(2) // download, remove (no mask for multiple images)
})
it('hides action buttons when not hovering', async () => {
const wrapper = mountImagePreview()
// Trigger hover
await wrapper.trigger('mouseenter')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(true)
// Trigger mouse leave
await wrapper.trigger('mouseleave')
await nextTick()
expect(wrapper.find('.actions').exists()).toBe(false)
})
it('shows mask/edit button only for single images', async () => {
// Multiple images - should not show mask button
const multipleImagesWrapper = mountImagePreview()
await multipleImagesWrapper.trigger('mouseenter')
await nextTick()
const maskButtonMultiple = multipleImagesWrapper.find(
'[aria-label="Edit or mask image"]'
)
expect(maskButtonMultiple.exists()).toBe(false)
// Single image - should show mask button
const singleImageWrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await singleImageWrapper.trigger('mouseenter')
await nextTick()
const maskButtonSingle = singleImageWrapper.find(
'[aria-label="Edit or mask image"]'
)
expect(maskButtonSingle.exists()).toBe(true)
})
it('handles action button clicks', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.trigger('mouseenter')
await nextTick()
// Test Edit/Mask button - just verify it can be clicked without errors
const editButton = wrapper.find('[aria-label="Edit or mask image"]')
expect(editButton.exists()).toBe(true)
await editButton.trigger('click')
// Test Remove button - just verify it can be clicked without errors
const removeButton = wrapper.find('[aria-label="Remove image"]')
expect(removeButton.exists()).toBe(true)
await removeButton.trigger('click')
})
it('handles download button click', async () => {
const wrapper = mountImagePreview({
imageUrls: [defaultProps.imageUrls[0]]
})
await wrapper.trigger('mouseenter')
await nextTick()
// Test Download button
const downloadButton = wrapper.find('[aria-label="Download image"]')
expect(downloadButton.exists()).toBe(true)
await downloadButton.trigger('click')
// Verify the mocked downloadFile was called
expect(downloadFile).toHaveBeenCalledWith(defaultProps.imageUrls[0])
})
it('switches images when navigation dots are clicked', async () => {
const wrapper = mountImagePreview()
// Initially shows first image
expect(wrapper.find('img').attributes('src')).toBe(
defaultProps.imageUrls[0]
)
// Click second navigation dot
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
await navigationDots[1].trigger('click')
await nextTick()
// After clicking, component shows loading state (Skeleton), not img
expect(wrapper.find('skeleton-stub').exists()).toBe(true)
expect(wrapper.find('img').exists()).toBe(false)
// Simulate image load event to clear loading state
const component = wrapper.vm as any
component.isLoading = false
await nextTick()
// Now should show second image
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('src')).toBe(defaultProps.imageUrls[1])
})
it('applies correct classes to navigation dots based on current image', async () => {
const wrapper = mountImagePreview()
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
// First dot should be active (has bg-white class)
expect(navigationDots[0].classes()).toContain('bg-white')
expect(navigationDots[1].classes()).toContain('bg-white/50')
// Switch to second image
await navigationDots[1].trigger('click')
await nextTick()
// Second dot should now be active
expect(navigationDots[0].classes()).toContain('bg-white/50')
expect(navigationDots[1].classes()).toContain('bg-white')
})
it('loads image without errors', async () => {
const wrapper = mountImagePreview()
const img = wrapper.find('img')
expect(img.exists()).toBe(true)
// Just verify the image element is properly set up
expect(img.attributes('src')).toBe(defaultProps.imageUrls[0])
})
it('has proper accessibility attributes', () => {
const wrapper = mountImagePreview()
const img = wrapper.find('img')
expect(img.attributes('alt')).toBe('Node output 1')
})
it('updates alt text when switching images', async () => {
const wrapper = mountImagePreview()
// Initially first image
expect(wrapper.find('img').attributes('alt')).toBe('Node output 1')
// Switch to second image
const navigationDots = wrapper.findAll('.w-2.h-2.rounded-full')
await navigationDots[1].trigger('click')
await nextTick()
// Simulate image load event to clear loading state
const component = wrapper.vm as any
component.isLoading = false
await nextTick()
// Alt text should update
const imgElement = wrapper.find('img')
expect(imgElement.exists()).toBe(true)
expect(imgElement.attributes('alt')).toBe('Node output 2')
})
})

View File

@@ -0,0 +1,199 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, toValue } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
const mockData = vi.hoisted(() => ({
mockNodeIds: new Set<string>(),
mockExecuting: false
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => {
const getCanvas = vi.fn()
const useCanvasStore = () => ({
getCanvas,
selectedNodeIds: computed(() => mockData.mockNodeIds)
})
return {
useCanvasStore
}
})
vi.mock(
'@/renderer/extensions/vueNodes/composables/useNodeEventHandlers',
() => {
const handleNodeSelect = vi.fn()
return { useNodeEventHandlers: () => ({ handleNodeSelect }) }
}
)
vi.mock(
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
() => ({
useVueElementTracking: vi.fn()
})
)
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: vi.fn()
})
}))
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
useNodeLayout: () => ({
position: { x: 100, y: 50 },
size: { width: 200, height: 100 },
startDrag: vi.fn(),
handleDrag: vi.fn(),
endDrag: vi.fn()
})
}))
vi.mock(
'@/renderer/extensions/vueNodes/execution/useNodeExecutionState',
() => ({
useNodeExecutionState: vi.fn(() => ({
executing: computed(() => mockData.mockExecuting),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
executionState: computed(() => 'idle' as const)
}))
})
)
vi.mock('@/renderer/extensions/vueNodes/preview/useNodePreviewState', () => ({
useNodePreviewState: vi.fn(() => ({
latestPreviewUrl: computed(() => ''),
shouldShowPreviewImg: computed(() => false)
}))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
'Node Render Error': 'Node Render Error'
}
}
})
function mountLGraphNode(props: ComponentProps<typeof LGraphNode>) {
return mount(LGraphNode, {
props,
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: true,
NodeContent: true,
SlotConnectionDot: true
}
}
})
}
const mockNodeData: VueNodeData = {
id: 'test-node-123',
title: 'Test Node',
type: 'TestNode',
mode: 0,
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
describe('LGraphNode', () => {
beforeEach(() => {
vi.resetAllMocks()
mockData.mockNodeIds = new Set()
mockData.mockExecuting = false
})
it('should call resize tracking composable with node ID', () => {
mountLGraphNode({ nodeData: mockNodeData })
expect(useVueElementTracking).toHaveBeenCalledWith(
expect.any(Function),
'node'
)
const idArg = vi.mocked(useVueElementTracking).mock.calls[0]?.[0]
const id = toValue(idArg)
expect(id).toEqual('test-node-123')
})
it('should render with data-node-id attribute', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.attributes('data-node-id')).toBe('test-node-123')
})
it('should render node title', () => {
// Don't stub NodeHeader for this test so we can see the title
const wrapper = mount(LGraphNode, {
props: { nodeData: mockNodeData },
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
}),
i18n
],
stubs: {
NodeSlots: true,
NodeWidgets: true,
NodeContent: true,
SlotConnectionDot: true
}
}
})
expect(wrapper.text()).toContain('Test Node')
})
it('should apply selected styling when selected prop is true', () => {
mockData.mockNodeIds = new Set(['test-node-123'])
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.classes()).toContain('outline-2')
expect(wrapper.classes()).toContain('outline-black')
expect(wrapper.classes()).toContain('dark-theme:outline-white')
})
it('should apply executing animation when executing prop is true', () => {
mockData.mockExecuting = true
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.classes()).toContain('animate-pulse')
})
it('should emit node-click event on pointer up', async () => {
const { handleNodeSelect } = useNodeEventHandlers()
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
await wrapper.trigger('pointerup')
expect(handleNodeSelect).toHaveBeenCalledOnce()
expect(handleNodeSelect).toHaveBeenCalledWith(
expect.any(PointerEvent),
mockNodeData,
expect.any(Boolean)
)
})
})

View File

@@ -0,0 +1,223 @@
/**
* Tests for NodeHeader subgraph functionality
*/
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
// Mock dependencies
vi.mock('@/scripts/app', () => ({
app: {
graph: null as any
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByLocatorId: vi.fn(),
getLocatorIdFromNodeData: vi.fn((nodeData) =>
nodeData.subgraphId
? `${nodeData.subgraphId}:${String(nodeData.id)}`
: String(nodeData.id)
)
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: vi.fn()
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key) => key)
}),
createI18n: vi.fn(() => ({
global: {
t: vi.fn((key) => key)
}
}))
}))
vi.mock('@/i18n', () => ({
st: vi.fn((key) => key),
t: vi.fn((key) => key),
i18n: {
global: {
t: vi.fn((key) => key)
}
}
}))
describe('NodeHeader - Subgraph Functionality', () => {
// Helper to setup common mocks
const setupMocks = async (isSubgraph = true, hasGraph = true) => {
const { app } = await import('@/scripts/app')
if (hasGraph) {
;(app as any).graph = { rootGraph: {} }
} else {
;(app as any).graph = null
}
vi.mocked(getNodeByLocatorId).mockReturnValue({
isSubgraphNode: () => isSubgraph
} as any)
}
beforeEach(() => {
vi.clearAllMocks()
})
const createMockNodeData = (
id: string,
subgraphId?: string
): VueNodeData => ({
id,
title: 'Test Node',
type: 'TestNode',
mode: 0,
selected: false,
executing: false,
subgraphId,
widgets: [],
inputs: [],
outputs: [],
hasErrors: false,
flags: {}
})
const createWrapper = (props = {}) => {
return mount(NodeHeader, {
props,
global: {
plugins: [createTestingPinia({ createSpy: vi.fn })],
mocks: {
$t: vi.fn((key: string) => key),
$primevue: { config: {} }
}
}
})
}
it('should show subgraph button for subgraph nodes', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(true)
})
it('should not show subgraph button for regular nodes', async () => {
await setupMocks(false) // isSubgraph = false
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(false)
})
it('should not show subgraph button in readonly mode', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: true
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(false)
})
it('should emit enter-subgraph event when button is clicked', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
await subgraphButton.trigger('click')
expect(wrapper.emitted('enter-subgraph')).toBeTruthy()
expect(wrapper.emitted('enter-subgraph')).toHaveLength(1)
})
it('should handle subgraph context correctly', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1', 'subgraph-id'),
readonly: false
})
await wrapper.vm.$nextTick()
// Should call getNodeByLocatorId with correct locator ID
expect(vi.mocked(getNodeByLocatorId)).toHaveBeenCalledWith(
expect.anything(),
'subgraph-id:test-node-1'
)
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(true)
})
it('should handle missing graph gracefully', async () => {
await setupMocks(true, false) // isSubgraph = true, hasGraph = false
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
expect(subgraphButton.exists()).toBe(false)
})
it('should prevent event propagation on double click', async () => {
await setupMocks(true) // isSubgraph = true
const wrapper = createWrapper({
nodeData: createMockNodeData('test-node-1'),
readonly: false
})
await wrapper.vm.$nextTick()
const subgraphButton = wrapper.find('[data-testid="subgraph-enter-button"]')
// Mock event object
const mockEvent = {
stopPropagation: vi.fn()
}
// Trigger dblclick event
await subgraphButton.trigger('dblclick', mockEvent)
// Should prevent propagation (handled by @dblclick.stop directive)
// This is tested by ensuring the component doesn't error and renders correctly
expect(subgraphButton.exists()).toBe(true)
})
})

View File

@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, shallowRef } from 'vue'
import {
type GraphNodeManager,
type VueNodeData,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import type {
LGraph,
LGraphCanvas,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
vi.mock('@/renderer/core/canvas/canvasStore', () => {
const canvas: Partial<LGraphCanvas> = {
select: vi.fn(),
deselect: vi.fn(),
deselectAll: vi.fn()
}
const updateSelectedItems = vi.fn()
return {
useCanvasStore: vi.fn(() => ({
canvas: canvas as LGraphCanvas,
updateSelectedItems,
selectedItems: []
}))
}
})
vi.mock('@/renderer/core/canvas/useCanvasInteractions', () => ({
useCanvasInteractions: vi.fn(() => ({
shouldHandleNodePointerEvents: computed(() => true) // Default to allowing pointer events
}))
}))
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => {
const setSource = vi.fn()
const bringNodeToFront = vi.fn()
return {
useLayoutMutations: vi.fn(() => ({
setSource,
bringNodeToFront
}))
}
})
vi.mock('@/composables/graph/useGraphNodeManager', () => {
const mockNode = {
id: 'node-1',
selected: false,
flags: { pinned: false }
}
const nodeManager = shallowRef({
getNode: vi.fn(() => mockNode as Partial<LGraphNode> as LGraphNode)
} as Partial<GraphNodeManager> as GraphNodeManager)
return {
useGraphNodeManager: vi.fn(() => nodeManager)
}
})
vi.mock('@/composables/graph/useVueNodeLifecycle', () => {
const nodeManager = useGraphNodeManager(undefined as unknown as LGraph)
return {
useVueNodeLifecycle: vi.fn(() => ({
nodeManager
}))
}
})
describe('useNodeEventHandlers', () => {
const { nodeManager: mockNodeManager } = useVueNodeLifecycle()
const mockNode = mockNodeManager.value!.getNode('fake_id')
const mockLayoutMutations = useLayoutMutations()
const testNodeData: VueNodeData = {
id: 'node-1',
title: 'Test Node',
type: 'test',
mode: 0,
selected: false,
executing: false
}
beforeEach(async () => {
vi.restoreAllMocks()
})
describe('handleNodeSelect', () => {
it('should select single node on regular click', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas, updateSelectedItems } = useCanvasStore()
const event = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: false,
metaKey: false
})
handleNodeSelect(event, testNodeData, false)
expect(canvas?.deselectAll).toHaveBeenCalledOnce()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(updateSelectedItems).toHaveBeenCalledOnce()
})
it('should toggle selection on ctrl+click', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
// Test selecting unselected node with ctrl
mockNode!.selected = false
const ctrlClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: true,
metaKey: false
})
handleNodeSelect(ctrlClickEvent, testNodeData, false)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
})
it('should deselect on ctrl+click of selected node', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
// Test deselecting selected node with ctrl
mockNode!.selected = true
const ctrlClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: true,
metaKey: false
})
handleNodeSelect(ctrlClickEvent, testNodeData, false)
expect(canvas?.deselect).toHaveBeenCalledWith(mockNode)
expect(canvas?.select).not.toHaveBeenCalled()
})
it('should handle meta key (Cmd) on Mac', () => {
const { handleNodeSelect } = useNodeEventHandlers()
const { canvas } = useCanvasStore()
mockNode!.selected = false
const metaClickEvent = new PointerEvent('pointerdown', {
bubbles: true,
ctrlKey: false,
metaKey: true
})
handleNodeSelect(metaClickEvent, testNodeData, false)
expect(canvas?.select).toHaveBeenCalledWith(mockNode)
expect(canvas?.deselectAll).not.toHaveBeenCalled()
})
it('should bring node to front when not pinned', () => {
const { handleNodeSelect } = useNodeEventHandlers()
mockNode!.flags.pinned = false
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData, false)
expect(mockLayoutMutations.bringNodeToFront).toHaveBeenCalledWith(
'node-1'
)
})
it('should not bring pinned node to front', () => {
const { handleNodeSelect } = useNodeEventHandlers()
mockNode!.flags.pinned = true
const event = new PointerEvent('pointerdown')
handleNodeSelect(event, testNodeData, false)
expect(mockLayoutMutations.bringNodeToFront).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
// Mock the layout mutations module
vi.mock('@/renderer/core/layout/operations/layoutMutations')
const mockedUseLayoutMutations = vi.mocked(useLayoutMutations)
describe('useNodeZIndex', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('bringNodeToFront', () => {
it('should bring node to front with default source', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex()
bringNodeToFront('node1')
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.Vue)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node1')
})
it('should bring node to front with custom source', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex()
bringNodeToFront('node2', LayoutSource.Canvas)
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.Canvas)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node2')
})
it('should use custom layout source from options', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex({
layoutSource: LayoutSource.External
})
bringNodeToFront('node3')
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.External)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node3')
})
it('should override layout source with explicit source parameter', () => {
const mockSetSource = vi.fn()
const mockBringNodeToFront = vi.fn()
mockedUseLayoutMutations.mockReturnValue({
setSource: mockSetSource,
bringNodeToFront: mockBringNodeToFront
} as Partial<ReturnType<typeof useLayoutMutations>> as ReturnType<
typeof useLayoutMutations
>)
const { bringNodeToFront } = useNodeZIndex({
layoutSource: LayoutSource.External
})
bringNodeToFront('node4', LayoutSource.Canvas)
expect(mockSetSource).toHaveBeenCalledWith(LayoutSource.Canvas)
expect(mockBringNodeToFront).toHaveBeenCalledWith('node4')
})
})
})

View File

@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
const mockSettingStore = reactive({
get: vi.fn(() => 8)
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
describe('useLOD', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
mockSettingStore.get.mockReturnValue(8)
})
it('should calculate isLOD value based on zoom threshold correctly', async () => {
vi.stubGlobal('devicePixelRatio', 1)
const camera = reactive({ z: 1 })
const { isLOD } = useLOD(camera)
await nextTick()
expect(isLOD.value).toBe(false)
camera.z = 0.55
await nextTick()
expect(isLOD.value).toBe(true)
camera.z = 0.87
await nextTick()
expect(isLOD.value).toBe(false)
})
it('should handle a different devicePixelRatio value', async () => {
vi.stubGlobal('devicePixelRatio', 3) //Threshold with 8px minFontsize = 0.19
const camera = reactive({ z: 1 })
const { isLOD } = useLOD(camera)
await nextTick()
expect(isLOD.value).toBe(false)
camera.z = 0.18
await nextTick()
expect(isLOD.value).toBe(true)
})
it('should respond to different minFontSize settings', async () => {
vi.stubGlobal('devicePixelRatio', 1)
mockSettingStore.get.mockReturnValue(16) //Now threshold is 1.14
const camera = reactive({ z: 1 })
const { isLOD } = useLOD(camera)
await nextTick()
expect(isLOD.value).toBe(true)
camera.z = 1.15
await nextTick()
expect(isLOD.value).toBe(false)
})
})

View File

@@ -0,0 +1,300 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { assetService } from '@/platform/assets/services/assetService'
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
const mockSettingStoreGet = vi.fn(() => false)
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: mockSettingStoreGet
}))
}))
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) =>
key === 'widgets.selectModel' ? 'Select model' : key
)
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
isAssetBrowserEligible: vi.fn(() => false)
}
}))
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => {
const mockAssetBrowserDialogShow = vi.fn()
return {
useAssetBrowserDialog: vi.fn(() => ({
show: mockAssetBrowserDialogShow
}))
}
})
// Test factory functions
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
const mockCallback = vi.fn()
return {
type: 'combo',
options: {},
name: 'testWidget',
value: undefined,
callback: mockCallback,
y: 0,
...overrides
} as IBaseWidget
}
function createMockNode(comfyClass = 'TestNode'): LGraphNode {
const node = new LGraphNode('TestNode')
node.comfyClass = comfyClass
// Spy on the addWidget method
vi.spyOn(node, 'addWidget').mockImplementation(
(type, name, value, callback) => {
const widget = createMockWidget({ type, name, value })
// Store the callback function on the widget for testing
if (typeof callback === 'function') {
widget.callback = callback
}
return widget
}
)
return node
}
function createMockInputSpec(overrides: Partial<InputSpec> = {}): InputSpec {
return {
type: 'COMBO',
name: 'testInput',
...overrides
} as InputSpec
}
describe('useComboWidget', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSettingStoreGet.mockReturnValue(false)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
vi.mocked(useAssetBrowserDialog).mockClear()
})
it('should handle undefined spec', () => {
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode()
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({ name: 'inputName' })
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'inputName',
undefined,
expect.any(Function),
expect.objectContaining({
values: []
})
)
expect(widget).toBe(mockWidget)
})
it('should create normal combo widget when asset API is disabled', () => {
mockSettingStoreGet.mockReturnValue(false) // Asset API disabled
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) // Widget is eligible
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
})
const widget = constructor(mockNode, inputSpec)
expect(widget).toBe(mockWidget)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'ckpt_name',
'model1.safetensors',
expect.any(Function),
{ values: ['model1.safetensors', 'model2.safetensors'] }
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(widget).toBe(mockWidget)
})
it('should create normal combo widget when widget is not eligible for asset browser', () => {
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode()
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'not_eligible_widget',
options: ['option1', 'option2']
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'not_eligible_widget',
'option1',
expect.any(Function),
{ values: ['option1', 'option2'] }
)
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
'not_eligible_widget',
'TestNode'
)
expect(widget).toBe(mockWidget)
})
it('should create asset browser widget when API enabled and widget eligible', () => {
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'model1.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'model1.safetensors',
expect.any(Function)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
'ckpt_name',
'CheckpointLoaderSimple'
)
expect(widget).toBe(mockWidget)
})
it('should create asset browser widget with options when API enabled and widget eligible', () => {
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'model1.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
options: ['model1.safetensors', 'model2.safetensors']
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'model1.safetensors',
expect.any(Function)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
'ckpt_name',
'CheckpointLoaderSimple'
)
expect(widget).toBe(mockWidget)
})
it('should use asset browser widget even when inputSpec has a default value but no options', () => {
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'fallback.safetensors'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name',
default: 'fallback.safetensors'
// Note: no options array provided
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'fallback.safetensors',
expect.any(Function)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
'ckpt_name',
'CheckpointLoaderSimple'
)
expect(widget).toBe(mockWidget)
})
it('should show Select model when asset widget has undefined current value', () => {
mockSettingStoreGet.mockReturnValue(true)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'asset',
name: 'ckpt_name',
value: 'Select model'
})
const mockNode = createMockNode('CheckpointLoaderSimple')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'ckpt_name'
// Note: no default, no options, not remote - getDefaultValue returns undefined
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'asset',
'ckpt_name',
'Select model', // Should fallback to this instead of undefined
expect.any(Function)
)
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
'ckpt_name',
'CheckpointLoaderSimple'
)
expect(widget).toBe(mockWidget)
})
})

View File

@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { _for_testing } from '@/composables/widgets/useFloatWidget'
import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
vi.mock('@/stores/settingStore', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
settings: {}
})

View File

@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { _for_testing } from '@/composables/widgets/useIntWidget'
import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
vi.mock('@/stores/settingStore', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
settings: {}
})

View File

@@ -1,8 +1,8 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRemoteWidget } from '@/composables/widgets/useRemoteWidget'
import { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { useRemoteWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
vi.mock('axios', () => {
return {
@@ -20,7 +20,7 @@ vi.mock('@/i18n', () => ({
}
}))
vi.mock('@/stores/settingStore', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
settings: {}
})

View File

@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest'
import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
import WidgetColorPicker from '@/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue'
import WidgetFileUpload from '@/renderer/extensions/vueNodes/widgets/components/WidgetFileUpload.vue'
import WidgetInputNumber from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import WidgetMarkdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue'
import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
import WidgetTextarea from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue'
import WidgetToggleSwitch from '@/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue'
import {
getComponent,
isEssential,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
describe('widgetRegistry', () => {
describe('getComponent', () => {
// Test number type mappings
describe('number types', () => {
it('should map int types to slider widget', () => {
expect(getComponent('int')).toBe(WidgetInputNumber)
expect(getComponent('INT')).toBe(WidgetInputNumber)
})
it('should map float types to slider widget', () => {
expect(getComponent('float')).toBe(WidgetInputNumber)
expect(getComponent('FLOAT')).toBe(WidgetInputNumber)
expect(getComponent('number')).toBe(WidgetInputNumber)
expect(getComponent('slider')).toBe(WidgetInputNumber)
})
})
// Test text type mappings
describe('text types', () => {
it('should map text variations to input text widget', () => {
expect(getComponent('text')).toBe(WidgetInputText)
expect(getComponent('string')).toBe(WidgetInputText)
expect(getComponent('STRING')).toBe(WidgetInputText)
})
it('should map multiline text types to textarea widget', () => {
expect(getComponent('multiline')).toBe(WidgetTextarea)
expect(getComponent('textarea')).toBe(WidgetTextarea)
expect(getComponent('TEXTAREA')).toBe(WidgetTextarea)
expect(getComponent('customtext')).toBe(WidgetTextarea)
})
it('should map markdown to markdown widget', () => {
expect(getComponent('MARKDOWN')).toBe(WidgetMarkdown)
expect(getComponent('markdown')).toBe(WidgetMarkdown)
})
})
// Test selection type mappings
describe('selection types', () => {
it('should map combo types to select widget', () => {
expect(getComponent('combo')).toBe(WidgetSelect)
expect(getComponent('COMBO')).toBe(WidgetSelect)
})
})
// Test boolean type mappings
describe('boolean types', () => {
it('should map boolean types to toggle switch widget', () => {
expect(getComponent('toggle')).toBe(WidgetToggleSwitch)
expect(getComponent('boolean')).toBe(WidgetToggleSwitch)
expect(getComponent('BOOLEAN')).toBe(WidgetToggleSwitch)
})
})
// Test advanced widget mappings
describe('advanced widgets', () => {
it('should map color types to color picker widget', () => {
expect(getComponent('color')).toBe(WidgetColorPicker)
expect(getComponent('COLOR')).toBe(WidgetColorPicker)
})
it('should map file types to file upload widget', () => {
expect(getComponent('file')).toBe(WidgetFileUpload)
expect(getComponent('fileupload')).toBe(WidgetFileUpload)
expect(getComponent('FILEUPLOAD')).toBe(WidgetFileUpload)
})
it('should map button types to button widget', () => {
expect(getComponent('button')).toBe(WidgetButton)
expect(getComponent('BUTTON')).toBe(WidgetButton)
})
})
// Test fallback behavior
describe('fallback behavior', () => {
it('should return null for unknown types', () => {
expect(getComponent('unknown')).toBe(null)
expect(getComponent('custom_widget')).toBe(null)
expect(getComponent('')).toBe(null)
})
})
})
describe('shouldRenderAsVue', () => {
it('should return false for widgets marked as canvas-only', () => {
const widget = { type: 'text', options: { canvasOnly: true } }
expect(shouldRenderAsVue(widget)).toBe(false)
})
it('should return false for widgets without a type', () => {
const widget = { options: {} }
expect(shouldRenderAsVue(widget)).toBe(false)
})
it('should return true for widgets with mapped types', () => {
expect(shouldRenderAsVue({ type: 'text' })).toBe(true)
expect(shouldRenderAsVue({ type: 'int' })).toBe(true)
expect(shouldRenderAsVue({ type: 'combo' })).toBe(true)
})
it('should return false for unknown types', () => {
expect(shouldRenderAsVue({ type: 'unknown_type' })).toBe(false)
})
it('should respect options while checking type', () => {
const widget = { type: 'text', options: { someOption: 'value' } }
expect(shouldRenderAsVue(widget)).toBe(true)
})
})
describe('isEssential', () => {
it('should identify essential widget types', () => {
expect(isEssential('int')).toBe(true)
expect(isEssential('INT')).toBe(true)
expect(isEssential('float')).toBe(true)
expect(isEssential('FLOAT')).toBe(true)
expect(isEssential('boolean')).toBe(true)
expect(isEssential('BOOLEAN')).toBe(true)
expect(isEssential('combo')).toBe(true)
expect(isEssential('COMBO')).toBe(true)
})
it('should identify non-essential widget types', () => {
expect(isEssential('button')).toBe(false)
expect(isEssential('color')).toBe(false)
expect(isEssential('chart')).toBe(false)
expect(isEssential('fileupload')).toBe(false)
})
it('should return false for unknown types', () => {
expect(isEssential('unknown')).toBe(false)
expect(isEssential('')).toBe(false)
})
})
describe('edge cases', () => {
it('should handle widgets with empty options', () => {
const widget = { type: 'text', options: {} }
expect(shouldRenderAsVue(widget)).toBe(true)
})
it('should handle case sensitivity correctly through aliases', () => {
// Test that both lowercase and uppercase work
expect(getComponent('string')).toBe(WidgetInputText)
expect(getComponent('STRING')).toBe(WidgetInputText)
expect(getComponent('combo')).toBe(WidgetSelect)
expect(getComponent('COMBO')).toBe(WidgetSelect)
})
})
})

View File

@@ -1,9 +1,10 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
vi.mock('@/renderer/thumbnail/graphThumbnailRenderer', () => ({
vi.mock('@/renderer/core/thumbnail/graphThumbnailRenderer', () => ({
createGraphThumbnail: vi.fn()
}))
@@ -19,10 +20,10 @@ vi.mock('@/scripts/api', () => ({
}))
const { useWorkflowThumbnail } = await import(
'@/renderer/thumbnail/composables/useWorkflowThumbnail'
'@/renderer/core/thumbnail/useWorkflowThumbnail'
)
const { createGraphThumbnail } = await import(
'@/renderer/thumbnail/graphThumbnailRenderer'
'@/renderer/core/thumbnail/graphThumbnailRenderer'
)
const { api } = await import('@/scripts/api')

View File

@@ -1,10 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyWorkflowJSON } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type {
HistoryResponse,
RawHistoryItem
} from '../../../src/schemas/apiSchema'
import type { ComfyWorkflowJSON } from '../../../src/schemas/comfyWorkflowSchema'
import { ComfyApi } from '../../../src/scripts/api'
describe('ComfyApi getHistory', () => {

View File

@@ -2,7 +2,7 @@ import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/brow
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAlgoliaSearchProvider } from '@/services/providers/algoliaSearchProvider'
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
import { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes'
// Mock global Algolia constants

View File

@@ -0,0 +1,304 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: vi.fn(() => ({
getRegisteredNodeTypes: vi.fn(
() =>
new Set([
'CheckpointLoaderSimple',
'LoraLoader',
'VAELoader',
'TestNode'
])
),
getCategoryForNodeType: mockGetCategoryForNodeType,
modelToNodeMap: {
checkpoints: [{ nodeDef: { name: 'CheckpointLoaderSimple' } }],
loras: [{ nodeDef: { name: 'LoraLoader' } }],
vae: [{ nodeDef: { name: 'VAELoader' } }]
}
}))
}))
// Helper to create API-compliant test assets
function createTestAsset(overrides: Partial<AssetItem> = {}) {
return {
id: 'test-uuid',
name: 'test-model.safetensors',
asset_hash: 'blake3:test123',
size: 123456,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
...overrides
}
}
// Test data constants
const MOCK_ASSETS = {
checkpoints: createTestAsset({
id: 'uuid-1',
name: 'model1.safetensors',
tags: ['models', 'checkpoints']
}),
loras: createTestAsset({
id: 'uuid-2',
name: 'model2.safetensors',
tags: ['models', 'loras']
}),
vae: createTestAsset({
id: 'uuid-3',
name: 'vae1.safetensors',
tags: ['models', 'vae']
})
} as const
// Helper functions
function mockApiResponse(assets: any[], options = {}) {
const response = {
assets,
total: assets.length,
has_more: false,
...options
}
vi.mocked(api.fetchApi).mockResolvedValueOnce(Response.json(response))
return response
}
function mockApiError(status: number, statusText = 'Error') {
vi.mocked(api.fetchApi).mockResolvedValueOnce(
new Response(null, { status, statusText })
)
}
describe('assetService', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.spyOn(api, 'fetchApi')
})
describe('getAssetModelFolders', () => {
it('should extract directory names from asset tags and filter blacklisted ones', async () => {
const assets = [
createTestAsset({
id: 'uuid-1',
name: 'checkpoint1.safetensors',
tags: ['models', 'checkpoints']
}),
createTestAsset({
id: 'uuid-2',
name: 'config.yaml',
tags: ['models', 'configs'] // Blacklisted
}),
createTestAsset({
id: 'uuid-3',
name: 'vae1.safetensors',
tags: ['models', 'vae']
})
]
mockApiResponse(assets)
const result = await assetService.getAssetModelFolders()
expect(api.fetchApi).toHaveBeenCalledWith('/assets?include_tags=models')
expect(result).toHaveLength(2)
const folderNames = result.map((f) => f.name)
expect(folderNames).toEqual(['checkpoints', 'vae'])
expect(folderNames).not.toContain('configs')
})
it('should handle empty responses', async () => {
mockApiResponse([])
const emptyResult = await assetService.getAssetModelFolders()
expect(emptyResult).toHaveLength(0)
})
it('should handle network errors', async () => {
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('Network error'))
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
'Network error'
)
})
it('should handle HTTP errors', async () => {
mockApiError(500)
await expect(assetService.getAssetModelFolders()).rejects.toThrow(
'Unable to load model folders: Server returned 500. Please try again.'
)
})
})
describe('getAssetModels', () => {
it('should return filtered models for folder', async () => {
const assets = [
{ ...MOCK_ASSETS.checkpoints, name: 'valid.safetensors' },
{ ...MOCK_ASSETS.loras, name: 'lora.safetensors' }, // Wrong tag
createTestAsset({
id: 'uuid-4',
name: 'missing-model.safetensors',
tags: ['models', 'checkpoints', 'missing'] // Has missing tag
})
]
mockApiResponse(assets)
const result = await assetService.getAssetModels('checkpoints')
expect(api.fetchApi).toHaveBeenCalledWith(
'/assets?include_tags=models,checkpoints'
)
expect(result).toEqual([
expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 })
])
})
it('should handle errors and empty responses', async () => {
// Empty response
mockApiResponse([])
const emptyResult = await assetService.getAssetModels('nonexistent')
expect(emptyResult).toEqual([])
// Network error
vi.mocked(api.fetchApi).mockRejectedValueOnce(new Error('Network error'))
await expect(assetService.getAssetModels('checkpoints')).rejects.toThrow(
'Network error'
)
// HTTP error
mockApiError(404)
await expect(assetService.getAssetModels('checkpoints')).rejects.toThrow(
'Unable to load models for checkpoints: Server returned 404. Please try again.'
)
})
})
describe('isAssetBrowserEligible', () => {
it('should return true for eligible widget names with registered node types', () => {
expect(
assetService.isAssetBrowserEligible(
'ckpt_name',
'CheckpointLoaderSimple'
)
).toBe(true)
expect(
assetService.isAssetBrowserEligible('lora_name', 'LoraLoader')
).toBe(true)
expect(assetService.isAssetBrowserEligible('vae_name', 'VAELoader')).toBe(
true
)
})
it('should return false for non-eligible widget names', () => {
expect(assetService.isAssetBrowserEligible('seed', 'TestNode')).toBe(
false
)
expect(assetService.isAssetBrowserEligible('steps', 'TestNode')).toBe(
false
)
expect(
assetService.isAssetBrowserEligible('sampler_name', 'TestNode')
).toBe(false)
expect(assetService.isAssetBrowserEligible('', 'TestNode')).toBe(false)
})
it('should return false for eligible widget names with unregistered node types', () => {
expect(
assetService.isAssetBrowserEligible('ckpt_name', 'UnknownNode')
).toBe(false)
expect(
assetService.isAssetBrowserEligible('lora_name', 'UnknownNode')
).toBe(false)
})
})
describe('getAssetsForNodeType', () => {
beforeEach(() => {
mockGetCategoryForNodeType.mockClear()
})
it('should return empty array for unregistered node types', async () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
const result = await assetService.getAssetsForNodeType('UnknownNode')
expect(mockGetCategoryForNodeType).toHaveBeenCalledWith('UnknownNode')
expect(result).toEqual([])
})
it('should use getCategoryForNodeType for efficient category lookup', async () => {
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
const testAssets = [MOCK_ASSETS.checkpoints]
mockApiResponse(testAssets)
const result = await assetService.getAssetsForNodeType(
'CheckpointLoaderSimple'
)
expect(mockGetCategoryForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple'
)
expect(result).toEqual(testAssets)
// Verify API call includes correct category
expect(api.fetchApi).toHaveBeenCalledWith(
'/assets?include_tags=models,checkpoints'
)
})
it('should return empty array when no category found', async () => {
mockGetCategoryForNodeType.mockReturnValue(undefined)
const result = await assetService.getAssetsForNodeType('TestNode')
expect(result).toEqual([])
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('should handle API errors gracefully', async () => {
mockGetCategoryForNodeType.mockReturnValue('loras')
mockApiError(500, 'Internal Server Error')
await expect(
assetService.getAssetsForNodeType('LoraLoader')
).rejects.toThrow(
'Unable to load assets for LoraLoader: Server returned 500. Please try again.'
)
})
it('should return all assets without filtering for different categories', async () => {
// Test checkpoints
mockGetCategoryForNodeType.mockReturnValue('checkpoints')
const checkpointAssets = [MOCK_ASSETS.checkpoints]
mockApiResponse(checkpointAssets)
let result = await assetService.getAssetsForNodeType(
'CheckpointLoaderSimple'
)
expect(result).toEqual(checkpointAssets)
// Test loras
mockGetCategoryForNodeType.mockReturnValue('loras')
const loraAssets = [MOCK_ASSETS.loras]
mockApiResponse(loraAssets)
result = await assetService.getAssetsForNodeType('LoraLoader')
expect(result).toEqual(loraAssets)
// Test vae
mockGetCategoryForNodeType.mockReturnValue('vae')
const vaeAssets = [MOCK_ASSETS.vae]
mockApiResponse(vaeAssets)
result = await assetService.getAssetsForNodeType('VAELoader')
expect(result).toEqual(vaeAssets)
})
})
})

View File

@@ -12,7 +12,7 @@ import {
} from '@/stores/keybindingStore'
// Mock stores
vi.mock('@/stores/settingStore', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: vi.fn(() => [])
}))

View File

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

View File

@@ -1,7 +1,7 @@
import axios from 'axios'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useReleaseService } from '@/services/releaseService'
import { useReleaseService } from '@/platform/updates/common/releaseService'
// Hoist the mock to avoid hoisting issues
const mockAxiosInstance = vi.hoisted(() => ({

View File

@@ -2,17 +2,49 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
InstalledPacksResponse,
ManagerPackInstalled
} from '@/types/comfyManagerTypes'
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'
vi.mock('@/services/comfyManagerService', () => ({
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)
@@ -33,11 +65,7 @@ interface EnabledDisabledTestCase {
}
describe('useComfyManagerStore', () => {
let mockManagerService: {
isLoading: ReturnType<typeof ref<boolean>>
error: ReturnType<typeof ref<string | null>>
listInstalledPacks: ReturnType<typeof vi.fn>
}
let mockManagerService: ReturnType<typeof useComfyManagerService>
const triggerPacksChange = async (
installedPacks: InstalledPacksResponse,
@@ -55,10 +83,22 @@ describe('useComfyManagerStore', () => {
mockManagerService = {
isLoading: ref(false),
error: ref(null),
listInstalledPacks: vi.fn().mockResolvedValue({})
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),
rebootComfyUI: vi.fn().mockResolvedValue(null),
isLegacyManagerUI: vi.fn().mockResolvedValue(false)
}
// @ts-expect-error Mocking the return type of useComfyManagerService
vi.mocked(useComfyManagerService).mockReturnValue(mockManagerService)
})
@@ -313,4 +353,183 @@ describe('useComfyManagerStore', () => {
}
)
})
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

@@ -1,12 +1,12 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useWorkflowStore } from '@/stores/workflowStore'
// Mock the workflowStore
vi.mock('@/stores/workflowStore', () => ({
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
nodeExecutionIdToNodeLocatorId: vi.fn(),
nodeIdToNodeLocatorId: vi.fn(),
@@ -16,7 +16,6 @@ vi.mock('@/stores/workflowStore', () => ({
// Remove any previous global types
declare global {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface Window {}
}

View File

@@ -1,8 +1,8 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { ExecutedWsMessage } from '@/schemas/apiSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import * as litegraphUtil from '@/utils/litegraphUtil'

View File

@@ -1,6 +1,8 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { useModelStore } from '@/stores/modelStore'
@@ -9,11 +11,41 @@ vi.mock('@/scripts/api', () => ({
api: {
getModels: vi.fn(),
getModelFolders: vi.fn(),
viewMetadata: vi.fn()
viewMetadata: vi.fn(),
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
function enableMocks() {
// Mock the assetService
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetModelFolders: vi.fn(),
getAssetModels: vi.fn()
}
}))
// Mock the settingStore
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn()
}))
function enableMocks(useAssetAPI = false) {
// Mock settingStore to return the useAssetAPI setting
const mockSettingStore = {
get: vi.fn().mockImplementation((key: string) => {
if (key === 'Comfy.Assets.UseAssetAPI') {
return useAssetAPI
}
return false
})
}
vi.mocked(useSettingStore, { partial: true }).mockReturnValue(
mockSettingStore
)
// Mock experimental API - returns objects with name and folders properties
vi.mocked(api.getModels).mockResolvedValue([
{ name: 'sdxl.safetensors', pathIndex: 0 },
{ name: 'sdv15.safetensors', pathIndex: 0 },
@@ -23,6 +55,18 @@ function enableMocks() {
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
{ name: 'vae', folders: ['/path/to/vae'] }
])
// Mock asset API - also returns objects with name and folders properties
vi.mocked(assetService.getAssetModelFolders).mockResolvedValue([
{ name: 'checkpoints', folders: ['/path/to/checkpoints'] },
{ name: 'vae', folders: ['/path/to/vae'] }
])
vi.mocked(assetService.getAssetModels).mockResolvedValue([
{ name: 'sdxl.safetensors', pathIndex: 0 },
{ name: 'sdv15.safetensors', pathIndex: 0 },
{ name: 'noinfo.safetensors', pathIndex: 0 }
])
vi.mocked(api.viewMetadata).mockImplementation((_, model) => {
if (model === 'noinfo.safetensors') {
return Promise.resolve({})
@@ -46,26 +90,25 @@ describe('useModelStore', () => {
beforeEach(async () => {
setActivePinia(createPinia())
store = useModelStore()
vi.resetAllMocks()
})
it('should load models', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore).not.toBeNull()
if (!folderStore) return
expect(Object.keys(folderStore.models).length).toBe(3)
expect(folderStore).toBeDefined()
expect(Object.keys(folderStore!.models)).toHaveLength(3)
})
it('should load model metadata', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore).not.toBeNull()
if (!folderStore) return
const model = folderStore.models['0/sdxl.safetensors']
expect(folderStore).toBeDefined()
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('Title of sdxl.safetensors')
expect(model.architecture_id).toBe('stable-diffusion-xl-base-v1')
@@ -79,11 +122,11 @@ describe('useModelStore', () => {
it('should handle no metadata', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore).not.toBeNull()
if (!folderStore) return
const model = folderStore.models['0/noinfo.safetensors']
expect(folderStore).toBeDefined()
const model = folderStore!.models['0/noinfo.safetensors']
await model.load()
expect(model.file_name).toBe('noinfo.safetensors')
expect(model.title).toBe('noinfo')
@@ -95,6 +138,7 @@ describe('useModelStore', () => {
it('should cache model information', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
expect(api.getModels).toHaveBeenCalledTimes(0)
await store.getLoadedModelFolder('checkpoints')
@@ -102,4 +146,36 @@ describe('useModelStore', () => {
await store.getLoadedModelFolder('checkpoints')
expect(api.getModels).toHaveBeenCalledTimes(1)
})
describe('API switching functionality', () => {
it('should use experimental API for complete workflow when UseAssetAPI setting is false', async () => {
enableMocks(false) // useAssetAPI = false
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
expect(api.getModelFolders).toHaveBeenCalledTimes(1)
expect(api.getModels).toHaveBeenCalledWith('checkpoints')
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(0)
expect(assetService.getAssetModels).toHaveBeenCalledTimes(0)
expect(folderStore).toBeDefined()
expect(Object.keys(folderStore!.models)).toHaveLength(3)
})
it('should use asset API for complete workflow when UseAssetAPI setting is true', async () => {
enableMocks(true) // useAssetAPI = true
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
// Both APIs return objects with .name property, modelStore extracts folder.name in both cases
expect(assetService.getAssetModelFolders).toHaveBeenCalledTimes(1)
expect(assetService.getAssetModels).toHaveBeenCalledWith('checkpoints')
expect(api.getModelFolders).toHaveBeenCalledTimes(0)
expect(api.getModels).toHaveBeenCalledTimes(0)
expect(folderStore).toBeDefined()
expect(Object.keys(folderStore!.models)).toHaveLength(3)
})
})
})

View File

@@ -0,0 +1,454 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import {
ModelNodeProvider,
useModelToNodeStore
} from '@/stores/modelToNodeStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
const EXPECTED_DEFAULT_TYPES = [
'checkpoints',
'loras',
'vae',
'controlnet',
'unet',
'upscale_models',
'style_models',
'gligen'
] as const
type NodeDefStoreType = ReturnType<typeof useNodeDefStore>
// Create minimal but valid ComfyNodeDefImpl for testing
function createMockNodeDef(name: string): ComfyNodeDefImpl {
const def: ComfyNodeDefV1 = {
name,
display_name: name,
category: 'test',
python_module: 'nodes',
description: '',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false
}
return new ComfyNodeDefImpl(def)
}
const MOCK_NODE_NAMES = [
'CheckpointLoaderSimple',
'ImageOnlyCheckpointLoader',
'LoraLoader',
'LoraLoaderModelOnly',
'VAELoader',
'ControlNetLoader',
'UNETLoader',
'UpscaleModelLoader',
'StyleModelLoader',
'GLIGENLoader'
] as const
const mockNodeDefsByName = Object.fromEntries(
MOCK_NODE_NAMES.map((name) => [name, createMockNodeDef(name)])
)
// Mock nodeDefStore dependency - modelToNodeStore relies on this for registration
// Most tests expect this to be populated; tests that need empty state can override
vi.mock('@/stores/nodeDefStore', async (importOriginal) => {
const original = await importOriginal<NodeDefStoreType>()
return {
...original,
useNodeDefStore: vi.fn(() => ({
nodeDefsByName: mockNodeDefsByName
}))
}
})
describe('useModelToNodeStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('modelToNodeMap', () => {
it('should initialize as empty', () => {
const modelToNodeStore = useModelToNodeStore()
expect(Object.keys(modelToNodeStore.modelToNodeMap)).toHaveLength(0)
})
it('should populate after registration', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(Object.keys(modelToNodeStore.modelToNodeMap)).toEqual(
expect.arrayContaining(['checkpoints', 'unet'])
)
})
})
describe('getNodeProvider', () => {
it('should return provider for registered model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const provider = modelToNodeStore.getNodeProvider('checkpoints')
expect(provider).toBeDefined()
// After asserting provider is defined, we can safely access its properties
expect(provider?.nodeDef?.name).toBe('CheckpointLoaderSimple')
expect(provider?.key).toBe('ckpt_name')
})
it('should return undefined for unregistered model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(modelToNodeStore.getNodeProvider('nonexistent')).toBeUndefined()
})
it('should return first registered provider when multiple providers exist for same model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const provider = modelToNodeStore.getNodeProvider('checkpoints')
// Using optional chaining for safety since getNodeProvider() can return undefined
expect(provider?.nodeDef?.name).toBe('CheckpointLoaderSimple')
})
it('should trigger lazy registration when called before registerDefaults', () => {
const modelToNodeStore = useModelToNodeStore()
const provider = modelToNodeStore.getNodeProvider('checkpoints')
expect(provider).toBeDefined()
})
})
describe('getAllNodeProviders', () => {
it('should return all providers for model type with multiple nodes', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const checkpointProviders =
modelToNodeStore.getAllNodeProviders('checkpoints')
expect(checkpointProviders).toHaveLength(2)
expect(checkpointProviders).toEqual(
expect.arrayContaining([
expect.objectContaining({
nodeDef: expect.objectContaining({ name: 'CheckpointLoaderSimple' })
}),
expect.objectContaining({
nodeDef: expect.objectContaining({
name: 'ImageOnlyCheckpointLoader'
})
})
])
)
const loraProviders = modelToNodeStore.getAllNodeProviders('loras')
expect(loraProviders).toHaveLength(2)
})
it('should return single provider for model type with one node', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const unetProviders = modelToNodeStore.getAllNodeProviders('unet')
expect(unetProviders).toHaveLength(1)
expect(unetProviders[0].nodeDef.name).toBe('UNETLoader')
})
it('should return empty array for unregistered model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(modelToNodeStore.getAllNodeProviders('nonexistent')).toEqual([])
})
it('should trigger lazy registration when called before registerDefaults', () => {
const modelToNodeStore = useModelToNodeStore()
const providers = modelToNodeStore.getAllNodeProviders('checkpoints')
expect(providers.length).toBeGreaterThan(0)
})
})
describe('registerNodeProvider', () => {
it('should register provider directly', () => {
const modelToNodeStore = useModelToNodeStore()
const nodeDefStore = useNodeDefStore()
const customProvider = new ModelNodeProvider(
nodeDefStore.nodeDefsByName['UNETLoader'],
'custom_key'
)
modelToNodeStore.registerNodeProvider('custom_type', customProvider)
const retrieved = modelToNodeStore.getNodeProvider('custom_type')
expect(retrieved).toStrictEqual(customProvider)
// Optional chaining for consistency with getNodeProvider() return type
expect(retrieved?.key).toBe('custom_key')
})
it('should handle multiple providers for same model type and return first as primary', () => {
const modelToNodeStore = useModelToNodeStore()
const nodeDefStore = useNodeDefStore()
const provider1 = new ModelNodeProvider(
nodeDefStore.nodeDefsByName['UNETLoader'],
'key1'
)
const provider2 = new ModelNodeProvider(
nodeDefStore.nodeDefsByName['VAELoader'],
'key2'
)
modelToNodeStore.registerNodeProvider('multi_type', provider1)
modelToNodeStore.registerNodeProvider('multi_type', provider2)
const allProviders = modelToNodeStore.getAllNodeProviders('multi_type')
expect(allProviders).toHaveLength(2)
expect(modelToNodeStore.getNodeProvider('multi_type')).toStrictEqual(
provider1
)
})
it('should initialize new model type when first provider is registered', () => {
const modelToNodeStore = useModelToNodeStore()
const nodeDefStore = useNodeDefStore()
expect(modelToNodeStore.modelToNodeMap['new_type']).toBeUndefined()
const provider = new ModelNodeProvider(
nodeDefStore.nodeDefsByName['UNETLoader'],
'test_key'
)
modelToNodeStore.registerNodeProvider('new_type', provider)
expect(modelToNodeStore.modelToNodeMap['new_type']).toBeDefined()
expect(modelToNodeStore.modelToNodeMap['new_type']).toHaveLength(1)
})
})
describe('quickRegister', () => {
it('should connect node class to model type with parameter mapping', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.quickRegister('test_type', 'UNETLoader', 'test_param')
const provider = modelToNodeStore.getNodeProvider('test_type')
expect(provider).toBeDefined()
// After asserting provider is defined, we can safely access its properties
expect(provider!.nodeDef.name).toBe('UNETLoader')
expect(provider!.key).toBe('test_param')
})
it('should handle registration of non-existent node classes gracefully', () => {
const modelToNodeStore = useModelToNodeStore()
expect(() => {
modelToNodeStore.quickRegister(
'test_type',
'NonExistentLoader',
'test_param'
)
}).not.toThrow()
const provider = modelToNodeStore.getNodeProvider('test_type')
// Optional chaining needed since getNodeProvider() can return undefined
expect(provider?.nodeDef).toBeUndefined()
})
it('should allow multiple node classes for same model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.quickRegister('multi_type', 'UNETLoader', 'param1')
modelToNodeStore.quickRegister('multi_type', 'VAELoader', 'param2')
const providers = modelToNodeStore.getAllNodeProviders('multi_type')
expect(providers).toHaveLength(2)
})
})
describe('registerDefaults integration', () => {
it('should register all expected model types based on mock data', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
for (const modelType of EXPECTED_DEFAULT_TYPES) {
expect.soft(modelToNodeStore.getNodeProvider(modelType)).toBeDefined()
}
})
it('should be idempotent', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const firstCheckpointCount =
modelToNodeStore.getAllNodeProviders('checkpoints').length
modelToNodeStore.registerDefaults() // Call again
const secondCheckpointCount =
modelToNodeStore.getAllNodeProviders('checkpoints').length
expect(secondCheckpointCount).toBe(firstCheckpointCount)
})
it('should not register when nodeDefStore is empty', () => {
// Create fresh Pinia for this test to avoid state persistence
setActivePinia(createPinia())
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
nodeDefsByName: {}
})
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(modelToNodeStore.getNodeProvider('checkpoints')).toBeUndefined()
// Restore original mock for subsequent tests
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
nodeDefsByName: mockNodeDefsByName
})
})
})
describe('getRegisteredNodeTypes', () => {
it('should return a Set instance', () => {
const modelToNodeStore = useModelToNodeStore()
const result = modelToNodeStore.getRegisteredNodeTypes()
expect(result).toBeInstanceOf(Set)
})
it('should return empty set when nodeDefStore is empty', () => {
// Create fresh Pinia for this test to avoid state persistence
setActivePinia(createPinia())
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
nodeDefsByName: {}
})
const modelToNodeStore = useModelToNodeStore()
const result = modelToNodeStore.getRegisteredNodeTypes()
expect(result.size).toBe(0)
// Restore original mock for subsequent tests
vi.mocked(useNodeDefStore, { partial: true }).mockReturnValue({
nodeDefsByName: mockNodeDefsByName
})
})
it('should contain node types for efficient Set.has() lookups', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
const result = modelToNodeStore.getRegisteredNodeTypes()
// Test Set.has() functionality which assetService depends on
expect(result.has('CheckpointLoaderSimple')).toBe(true)
expect(result.has('LoraLoader')).toBe(true)
expect(result.has('NonExistentNode')).toBe(false)
})
})
describe('getCategoryForNodeType', () => {
it('should return category for known node type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(
modelToNodeStore.getCategoryForNodeType('CheckpointLoaderSimple')
).toBe('checkpoints')
expect(modelToNodeStore.getCategoryForNodeType('LoraLoader')).toBe(
'loras'
)
expect(modelToNodeStore.getCategoryForNodeType('VAELoader')).toBe('vae')
})
it('should return undefined for unknown node type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(
modelToNodeStore.getCategoryForNodeType('NonExistentNode')
).toBeUndefined()
expect(modelToNodeStore.getCategoryForNodeType('')).toBeUndefined()
})
it('should return first category when node type exists in multiple categories', () => {
const modelToNodeStore = useModelToNodeStore()
// Test with a node that exists in the defaults but add our own first
// Since defaults register 'StyleModelLoader' in 'style_models',
// we verify our custom registrations come after defaults in Object.entries iteration
const result = modelToNodeStore.getCategoryForNodeType('StyleModelLoader')
expect(result).toBe('style_models') // This proves the method works correctly
// Now test that custom registrations after defaults also work
modelToNodeStore.quickRegister(
'unicorn_styles',
'StyleModelLoader',
'param1'
)
const result2 =
modelToNodeStore.getCategoryForNodeType('StyleModelLoader')
// Should still be style_models since it was registered first by defaults
expect(result2).toBe('style_models')
})
it('should trigger lazy registration when called before registerDefaults', () => {
const modelToNodeStore = useModelToNodeStore()
const result = modelToNodeStore.getCategoryForNodeType(
'CheckpointLoaderSimple'
)
expect(result).toBe('checkpoints')
})
it('should be performant for repeated lookups', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
// Measure performance without assuming implementation
const start = performance.now()
for (let i = 0; i < 1000; i++) {
modelToNodeStore.getCategoryForNodeType('CheckpointLoaderSimple')
}
const end = performance.now()
// Should be fast enough for UI responsiveness
expect(end - start).toBeLessThan(10)
})
it('should handle invalid input types gracefully', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
// These should not throw but return undefined
expect(
modelToNodeStore.getCategoryForNodeType(null as any)
).toBeUndefined()
expect(
modelToNodeStore.getCategoryForNodeType(undefined as any)
).toBeUndefined()
expect(
modelToNodeStore.getCategoryForNodeType(123 as any)
).toBeUndefined()
})
it('should be case-sensitive for node type matching', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
expect(
modelToNodeStore.getCategoryForNodeType('checkpointloadersimple')
).toBeUndefined()
expect(
modelToNodeStore.getCategoryForNodeType('CHECKPOINTLOADERSIMPLE')
).toBeUndefined()
expect(
modelToNodeStore.getCategoryForNodeType('CheckpointLoaderSimple')
).toBe('checkpoints')
})
})
describe('edge cases', () => {
it('should handle empty string model type', () => {
const modelToNodeStore = useModelToNodeStore()
expect(modelToNodeStore.getNodeProvider('')).toBeUndefined()
expect(modelToNodeStore.getAllNodeProviders('')).toEqual([])
})
})
})

View File

@@ -1,14 +1,20 @@
import { createPinia, setActivePinia } from 'pinia'
import { compare as semverCompare } from 'semver'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useReleaseStore } from '@/stores/releaseStore'
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
// Mock the dependencies
vi.mock('@/utils/formatUtil')
vi.mock('semver')
vi.mock('@/utils/envUtil')
vi.mock('@/services/releaseService')
vi.mock('@/stores/settingStore')
vi.mock('@/platform/updates/common/releaseService')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/systemStatsStore')
vi.mock('@vueuse/core', () => ({
until: vi.fn(() => Promise.resolve()),
useStorage: vi.fn(() => ({ value: {} })),
createSharedComposable: vi.fn((fn) => fn)
}))
describe('useReleaseStore', () => {
let store: ReturnType<typeof useReleaseStore>
@@ -49,13 +55,16 @@ describe('useReleaseStore', () => {
comfyui_version: '1.0.0'
}
},
fetchSystemStats: vi.fn(),
isInitialized: true,
refetchSystemStats: vi.fn(),
getFormFactor: vi.fn(() => 'git-windows')
}
// Setup mock implementations
const { useReleaseService } = await import('@/services/releaseService')
const { useSettingStore } = await import('@/stores/settingStore')
const { useReleaseService } = await import(
'@/platform/updates/common/releaseService'
)
const { useSettingStore } = await import('@/platform/settings/settingStore')
const { useSystemStatsStore } = await import('@/stores/systemStatsStore')
const { isElectron } = await import('@/utils/envUtil')
@@ -106,17 +115,15 @@ describe('useReleaseStore', () => {
expect(store.recentReleases).toEqual(releases.slice(0, 3))
})
it('should show update button (shouldShowUpdateButton)', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1) // newer version available
it('should show update button (shouldShowUpdateButton)', () => {
vi.mocked(semverCompare).mockReturnValue(1) // newer version available
store.releases = [mockRelease]
expect(store.shouldShowUpdateButton).toBe(true)
})
it('should not show update button when no new version', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(-1) // current version is newer
it('should not show update button when no new version', () => {
vi.mocked(semverCompare).mockReturnValue(-1) // current version is newer
store.releases = [mockRelease]
expect(store.shouldShowUpdateButton).toBe(false)
@@ -124,21 +131,20 @@ describe('useReleaseStore', () => {
})
describe('showVersionUpdates setting', () => {
beforeEach(() => {
beforeEach(async () => {
store.releases = [mockRelease]
})
describe('when notifications are enabled', () => {
beforeEach(() => {
beforeEach(async () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
})
it('should show toast for medium/high attention releases', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should show toast for medium/high attention releases', () => {
vi.mocked(semverCompare).mockReturnValue(1)
// Need multiple releases for hasMediumOrHighAttention to work
const mediumRelease = {
@@ -151,17 +157,16 @@ describe('useReleaseStore', () => {
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot for new versions', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should show red dot for new versions', () => {
vi.mocked(semverCompare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', async () => {
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
vi.mocked(semverCompare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(true)
})
@@ -174,37 +179,36 @@ describe('useReleaseStore', () => {
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows'
form_factor: 'git-windows',
locale: 'en'
})
})
})
describe('when notifications are disabled', () => {
beforeEach(() => {
beforeEach(async () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return false
return null
})
})
it('should not show toast even with new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should not show toast even with new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
expect(store.shouldShowToast).toBe(false)
})
it('should not show red dot even with new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should not show red dot even with new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should not show popup even for latest version', async () => {
it('should not show popup even for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
vi.mocked(semverCompare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(false)
})
@@ -233,7 +237,8 @@ describe('useReleaseStore', () => {
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows'
form_factor: 'git-windows',
locale: 'en'
})
expect(store.releases).toEqual([mockRelease])
})
@@ -247,7 +252,8 @@ describe('useReleaseStore', () => {
expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac'
form_factor: 'desktop-mac',
locale: 'en'
})
})
@@ -334,12 +340,15 @@ describe('useReleaseStore', () => {
})
it('should fetch system stats if not available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
expect(until).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
})
it('should not set loading state when notifications disabled', async () => {
@@ -401,18 +410,20 @@ describe('useReleaseStore', () => {
})
it('should proceed with fetchReleases when system stats are not available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.fetchReleases()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
expect(until).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
})
})
describe('action handlers', () => {
beforeEach(() => {
beforeEach(async () => {
store.releases = [mockRelease]
})
@@ -469,7 +480,7 @@ describe('useReleaseStore', () => {
})
describe('popup visibility', () => {
it('should show toast for medium/high attention releases', async () => {
it('should show toast for medium/high attention releases', () => {
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Release.Version') return null
if (key === 'Comfy.Release.Status') return null
@@ -477,8 +488,7 @@ describe('useReleaseStore', () => {
return null
})
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
vi.mocked(semverCompare).mockReturnValue(1)
const mediumRelease = { ...mockRelease, attention: 'medium' as const }
store.releases = [
@@ -490,9 +500,8 @@ describe('useReleaseStore', () => {
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot for new versions', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should show red dot for new versions', () => {
vi.mocked(semverCompare).mockReturnValue(1)
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
@@ -503,15 +512,14 @@ describe('useReleaseStore', () => {
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', async () => {
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0' // Same as release
mockSettingStore.get.mockImplementation((key: string) => {
if (key === 'Comfy.Notification.ShowVersionUpdates') return true
return null
})
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0) // versions are equal (latest version)
vi.mocked(semverCompare).mockReturnValue(0) // versions are equal (latest version)
store.releases = [mockRelease]
@@ -530,7 +538,7 @@ describe('useReleaseStore', () => {
await store.initialize()
// Should not fetch system stats when notifications disabled
expect(mockSystemStatsStore.fetchSystemStats).not.toHaveBeenCalled()
expect(mockSystemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
})
it('should handle concurrent fetchReleases calls', async () => {
@@ -553,7 +561,7 @@ describe('useReleaseStore', () => {
})
describe('isElectron environment checks', () => {
beforeEach(() => {
beforeEach(async () => {
// Set up a new version available
store.releases = [mockRelease]
mockSettingStore.get.mockImplementation((key: string) => {
@@ -568,9 +576,8 @@ describe('useReleaseStore', () => {
vi.mocked(isElectron).mockReturnValue(true)
})
it('should show toast when conditions are met', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should show toast when conditions are met', () => {
vi.mocked(semverCompare).mockReturnValue(1)
// Need multiple releases for hasMediumOrHighAttention
const mediumRelease = {
@@ -583,17 +590,16 @@ describe('useReleaseStore', () => {
expect(store.shouldShowToast).toBe(true)
})
it('should show red dot when new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should show red dot when new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(true)
})
it('should show popup for latest version', async () => {
it('should show popup for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
vi.mocked(semverCompare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(true)
})
@@ -605,9 +611,8 @@ describe('useReleaseStore', () => {
vi.mocked(isElectron).mockReturnValue(false)
})
it('should NOT show toast even when all other conditions are met', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should NOT show toast even when all other conditions are met', () => {
vi.mocked(semverCompare).mockReturnValue(1)
// Set up all conditions that would normally show toast
const mediumRelease = {
@@ -620,16 +625,14 @@ describe('useReleaseStore', () => {
expect(store.shouldShowToast).toBe(false)
})
it('should NOT show red dot even when new version available', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should NOT show red dot even when new version available', () => {
vi.mocked(semverCompare).mockReturnValue(1)
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show toast regardless of attention level', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should NOT show toast regardless of attention level', () => {
vi.mocked(semverCompare).mockReturnValue(1)
// Test with high attention releases
const highRelease = {
@@ -647,19 +650,18 @@ describe('useReleaseStore', () => {
expect(store.shouldShowToast).toBe(false)
})
it('should NOT show red dot even with high attention release', async () => {
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(1)
it('should NOT show red dot even with high attention release', () => {
vi.mocked(semverCompare).mockReturnValue(1)
store.releases = [{ ...mockRelease, attention: 'high' as const }]
expect(store.shouldShowRedDot).toBe(false)
})
it('should NOT show popup even for latest version', async () => {
it('should NOT show popup even for latest version', () => {
mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0'
const { compareVersions } = await import('@/utils/formatUtil')
vi.mocked(compareVersions).mockReturnValue(0)
vi.mocked(semverCompare).mockReturnValue(0)
expect(store.shouldShowPopup).toBe(false)
})

View File

@@ -2,7 +2,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type NodeSearchBoxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import type { useSettingStore } from '@/stores/settingStore'
import type { useSettingStore } from '@/platform/settings/settingStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
// Mock dependencies
@@ -14,7 +14,7 @@ vi.mock('@vueuse/core', () => ({
}))
const mockSettingStore = createMockSettingStore()
vi.mock('@/stores/settingStore', () => ({
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => mockSettingStore)
}))

View File

@@ -1,9 +1,9 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { ServerConfig } from '@/constants/serverConfig'
import type { ServerConfig } from '@/constants/serverConfig'
import type { FormItem } from '@/platform/settings/types'
import { useServerConfigStore } from '@/stores/serverConfigStore'
import type { FormItem } from '@/types/settingTypes'
const dummyFormItem: FormItem = {
name: '',

View File

@@ -1,10 +1,13 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
getSettingInfo,
useSettingStore
} from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { getSettingInfo, useSettingStore } from '@/stores/settingStore'
import type { SettingParams } from '@/types/settingTypes'
// Mock the api
vi.mock('@/scripts/api', () => ({

View File

@@ -2,10 +2,10 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { ComfyWorkflow } from '@/stores/workflowStore'
vi.mock('@/scripts/app', () => {
const mockCanvas = {
@@ -34,7 +34,7 @@ vi.mock('@/scripts/app', () => {
}
})
vi.mock('@/stores/graphStore', () => ({
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => (app as any).canvas
})

View File

@@ -2,10 +2,10 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { ComfyWorkflow } from '@/stores/workflowStore'
vi.mock('@/scripts/app', () => {
const mockCanvas = {
@@ -35,7 +35,7 @@ vi.mock('@/scripts/app', () => {
})
// Mock canvasStore
vi.mock('@/stores/graphStore', () => ({
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => (app as any).canvas
})

View File

@@ -0,0 +1,128 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/test/subgraph/fixtures/subgraphHelpers'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {
getUserData: vi.fn(),
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
apiURL: vi.fn(),
addEventListener: vi.fn()
}
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
prompt: () => 'testname',
confirm: () => true
}))
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => ({
getCanvas: () => comfyApp.canvas
}))
}))
// Mock comfyApp globally for the store setup
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
_deserializeItems: vi.fn((i) => i),
ds: { visible_area: [0, 0, 0, 0] },
selected_nodes: null
},
loadGraphData: vi.fn()
}
}))
const mockGraph = {
nodes: [{ type: '123' }],
definitions: { subgraphs: [{ id: '123' }] }
}
describe('useSubgraphStore', () => {
let store: ReturnType<typeof useSubgraphStore>
const mockFetch = async (filenames: Record<string, unknown>) => {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue(
Object.keys(filenames).map((filename) => ({
path: filename,
modified: new Date().getTime(),
size: 1 // size !== -1 for remote workflows
}))
)
vi.mocked(api).getUserData = vi.fn(
(f) =>
({
status: 200,
text: () => JSON.stringify(filenames[f.slice(10)])
}) as any
)
return await store.fetchSubgraphs()
}
beforeEach(() => {
setActivePinia(createPinia())
store = useSubgraphStore()
vi.clearAllMocks()
})
it('should allow publishing of a subgraph', async () => {
//mock canvas to provide a minimal subgraphNode
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph
graph.add(subgraphNode)
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => ({
nodes: [subgraphNode.serialize()],
subgraphs: [subgraph.serialize() as any]
}))
//mock saving of file
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () =>
Promise.resolve({
path: 'subgraphs/testname.json',
modified: Date.now(),
size: 2
})
} as Response)
await mockFetch({ 'testname.json': mockGraph })
//Dialogue service already mocked
await store.publishSubgraph()
expect(api.storeUserData).toHaveBeenCalled()
})
it('should display published nodes in the node library', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(
useNodeDefStore().nodeDefs.filter(
(d) => d.category == 'Subgraph Blueprints'
)
).toHaveLength(1)
})
it('should allow subgraphs to be edited', async () => {
await mockFetch({ 'test.json': mockGraph })
await store.editBlueprint(store.typePrefix + 'test')
//check active graph
expect(comfyApp.loadGraphData).toHaveBeenCalled()
})
it('should allow subgraphs to be added to graph', async () => {
//mock
await mockFetch({ 'test.json': mockGraph })
const res = useLitegraphService().addNodeOnGraph({
name: 'SubgraphBlueprint.test'
} as ComfyNodeDefV1)
expect(res).toBeTruthy()
})
})

Some files were not shown because too many files have changed in this diff Show More