Compare commits

..

8 Commits

Author SHA1 Message Date
Robin Huang
52a93f620e Add algolia search domain. 2025-05-25 09:58:05 -07:00
Comfy Org PR Bot
6d87f2b2ff 1.20.3 (#3932)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-18 21:26:18 -04:00
Christian Byrne
20911aa892 docs: improve README development section organization (#3929)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-05-19 09:38:59 +10:00
Christian Byrne
3a6018589e Add testing documentation guides for frontend tests (#3927) 2025-05-19 08:41:51 +10:00
Christian Byrne
4c92a7142e Fix: Close user popover on button clicks (#3928) 2025-05-19 08:41:33 +10:00
Christian Byrne
293993e7de Hide manager button in missing nodes dialog when manager is not installed (#3925) 2025-05-18 12:16:24 -04:00
Christian Byrne
a7ee3fae05 Add tests for ChatHistoryWidget and related features (#3921) 2025-05-18 12:16:06 -04:00
Christian Byrne
22dc84324e [docs] add READMEs for major folders (#3923) 2025-05-18 12:11:56 -04:00
24 changed files with 1684 additions and 610 deletions

View File

@@ -526,11 +526,20 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
## Development
### Prerequisites
### Prerequisites & Technology Stack
- Node.js (v16 or later) and npm must be installed
- Git for version control
- A running ComfyUI backend instance
- **Required Software**:
- Node.js (v16 or later) and npm
- Git for version control
- A running ComfyUI backend instance
- **Tech Stack**:
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
- [Pinia](https://pinia.vuejs.org/) for state management
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
- [litegraph.js](https://github.com/Comfy-Org/litegraph.js) for node editor
- [zod](https://zod.dev/) for schema validation
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
### Initial Setup
@@ -558,15 +567,6 @@ To launch ComfyUI and have it connect to your development server:
python main.py --port 8188
```
### Tech Stack
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
- [Pinia](https://pinia.vuejs.org/) for state management
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
- [litegraph.js](https://github.com/Comfy-Org/litegraph.js) for node editor
- [zod](https://zod.dev/) for schema validation
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
### Git pre-commit hooks
Run `npm run prepare` to install Git pre-commit hooks. Currently, the pre-commit
@@ -579,6 +579,7 @@ core extensions will be loaded.
- Start local ComfyUI backend at `localhost:8188`
- Run `npm run dev` to start the dev server
- Run `npm run dev:electron` to start the dev server with electron API mocked
#### Access dev server on touch devices

View File

@@ -0,0 +1,139 @@
import { Page, expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
interface ChatHistoryEntry {
prompt: string
response: string
response_id: string
}
async function renderChatHistory(page: Page, history: ChatHistoryEntry[]) {
const nodeId = await page.evaluate(() => window['app'].graph.nodes[0]?.id)
// Simulate API sending display_component message
await page.evaluate(
({ nodeId, history }) => {
const event = new CustomEvent('display_component', {
detail: {
node_id: nodeId,
component: 'ChatHistoryWidget',
props: {
history: JSON.stringify(history)
}
}
})
window['app'].api.dispatchEvent(event)
return true
},
{ nodeId, history }
)
return nodeId
}
test.describe('Chat History Widget', () => {
let nodeId: string
test.beforeEach(async ({ comfyPage }) => {
nodeId = await renderChatHistory(comfyPage.page, [
{ prompt: 'Hello', response: 'World', response_id: '123' }
])
// Wait for chat history to be rendered
await comfyPage.page.waitForSelector('.pi-pencil')
})
test('displays chat history when receiving display_component message', async ({
comfyPage
}) => {
// Verify the chat history is displayed correctly
await expect(comfyPage.page.getByText('Hello')).toBeVisible()
await expect(comfyPage.page.getByText('World')).toBeVisible()
})
test('handles message editing interaction', async ({ comfyPage }) => {
// Get first node's ID
nodeId = await comfyPage.page.evaluate(() => {
const node = window['app'].graph.nodes[0]
// Make sure the node has a prompt widget (for editing functionality)
if (!node.widgets) {
node.widgets = []
}
// Add a prompt widget if it doesn't exist
if (!node.widgets.find((w) => w.name === 'prompt')) {
node.widgets.push({
name: 'prompt',
type: 'text',
value: 'Original prompt'
})
}
return node.id
})
await renderChatHistory(comfyPage.page, [
{
prompt: 'Message 1',
response: 'Response 1',
response_id: '123'
},
{
prompt: 'Message 2',
response: 'Response 2',
response_id: '456'
}
])
await comfyPage.page.waitForSelector('.pi-pencil')
const originalTextAreaInput = await comfyPage.page
.getByPlaceholder('text')
.nth(1)
.inputValue()
// Click edit button on first message
await comfyPage.page.getByLabel('Edit').first().click()
await comfyPage.nextFrame()
// Verify cancel button appears
await expect(comfyPage.page.getByLabel('Cancel')).toBeVisible()
// Click cancel edit
await comfyPage.page.getByLabel('Cancel').click()
// Verify prompt input is restored
await expect(comfyPage.page.getByPlaceholder('text').nth(1)).toHaveValue(
originalTextAreaInput
)
})
test('handles real-time updates to chat history', async ({ comfyPage }) => {
// Send initial history
await renderChatHistory(comfyPage.page, [
{
prompt: 'Initial message',
response: 'Initial response',
response_id: '123'
}
])
await comfyPage.page.waitForSelector('.pi-pencil')
// Update history with additional messages
await renderChatHistory(comfyPage.page, [
{
prompt: 'Follow-up',
response: 'New response',
response_id: '456'
}
])
await comfyPage.page.waitForSelector('.pi-pencil')
// Move mouse over the canvas to force update
await comfyPage.page.mouse.move(100, 100)
await comfyPage.nextFrame()
// Verify new messages appear
await expect(comfyPage.page.getByText('Follow-up')).toBeVisible()
await expect(comfyPage.page.getByText('New response')).toBeVisible()
})
})

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.20.2",
"version": "1.20.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.20.2",
"version": "1.20.3",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.20.2",
"version": "1.20.3",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -30,7 +30,7 @@
</div>
</template>
</ListBox>
<div class="flex justify-end py-3">
<div v-if="isManagerInstalled" class="flex justify-end py-3">
<Button label="Open Manager" size="small" outlined @click="openManager" />
</div>
</template>
@@ -42,6 +42,7 @@ import { computed } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes'
@@ -49,6 +50,19 @@ const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
const aboutPanelStore = useAboutPanelStore()
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core
const isManagerInstalled = computed(() => {
return aboutPanelStore.badges.some(
(badge) =>
badge.label.includes('ComfyUI-Manager') ||
badge.url.includes('ComfyUI-Manager')
)
})
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return props.missingNodeTypes

View File

@@ -96,8 +96,7 @@ const setPromptInput = (text: string, previousResponseId?: string | null) => {
}
const handleEdit = (index: number) => {
if (!promptInput) return
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
editIndex.value = index
const prevResponseId = index === 0 ? 'start' : getPreviousResponseId(index)
const promptText = parsedHistory.value[index]?.prompt ?? ''

View File

@@ -0,0 +1,122 @@
import { VueWrapper, mount } from '@vue/test-utils'
import Button from 'primevue/button'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import CurrentUserButton from './CurrentUserButton.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
// Mock pinia
vi.mock('pinia')
// Mock the useCurrentUser composable
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
isLoggedIn: true,
userPhotoUrl: 'https://example.com/avatar.jpg',
userDisplayName: 'Test User',
userEmail: 'test@example.com'
}))
}))
// Mock the UserAvatar component
vi.mock('@/components/common/UserAvatar.vue', () => ({
default: {
name: 'UserAvatarMock',
render() {
return h('div', 'Avatar')
}
}
}))
// Mock the CurrentUserPopover component
vi.mock('./CurrentUserPopover.vue', () => ({
default: {
name: 'CurrentUserPopoverMock',
render() {
return h('div', 'Popover Content')
},
emits: ['close']
}
}))
describe('CurrentUserButton', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const mountComponent = (): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(CurrentUserButton, {
global: {
plugins: [i18n],
stubs: {
// Use shallow mount for popover to make testing easier
Popover: {
template: '<div><slot></slot></div>',
methods: {
toggle: vi.fn(),
hide: vi.fn()
}
},
Button: true
}
}
})
}
it('renders correctly when user is logged in', () => {
const wrapper = mountComponent()
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
it('toggles popover on button click', async () => {
const wrapper = mountComponent()
const popoverToggleSpy = vi.fn()
// Override the ref with a mock implementation
// @ts-expect-error - accessing internal Vue component vm
wrapper.vm.popover = { toggle: popoverToggleSpy }
await wrapper.findComponent(Button).trigger('click')
expect(popoverToggleSpy).toHaveBeenCalled()
})
it('hides popover when closePopover is called', async () => {
const wrapper = mountComponent()
// Replace the popover.hide method with a spy
const popoverHideSpy = vi.fn()
// @ts-expect-error - accessing internal Vue component vm
wrapper.vm.popover = { hide: popoverHideSpy }
// Directly call the closePopover method through the component instance
// @ts-expect-error - accessing internal Vue component vm
wrapper.vm.closePopover()
// Verify that popover.hide was called
expect(popoverHideSpy).toHaveBeenCalled()
})
})

View File

@@ -19,7 +19,7 @@
</Button>
<Popover ref="popover" :show-arrow="false">
<CurrentUserPopover />
<CurrentUserPopover @close="closePopover" />
</Popover>
</div>
</template>
@@ -40,4 +40,8 @@ const popover = ref<InstanceType<typeof Popover> | null>(null)
const photoURL = computed<string | undefined>(
() => userPhotoUrl.value ?? undefined
)
const closePopover = () => {
popover.value?.hide()
}
</script>

View File

@@ -0,0 +1,173 @@
import { VueWrapper, mount } from '@vue/test-utils'
import Button from 'primevue/button'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import CurrentUserPopover from './CurrentUserPopover.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
// Mock pinia
vi.mock('pinia')
// Mock showSettingsDialog and showTopUpCreditsDialog
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
// Mock window.open
const originalWindowOpen = window.open
beforeEach(() => {
window.open = vi.fn()
})
afterAll(() => {
window.open = originalWindowOpen
})
// Mock the useCurrentUser composable
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
userPhotoUrl: 'https://example.com/avatar.jpg',
userDisplayName: 'Test User',
userEmail: 'test@example.com'
}))
}))
// Mock the useFirebaseAuthActions composable
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined)
}))
}))
// Mock the dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showSettingsDialog: mockShowSettingsDialog,
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
}))
}))
// Mock UserAvatar component
vi.mock('@/components/common/UserAvatar.vue', () => ({
default: {
name: 'UserAvatarMock',
render() {
return h('div', 'Avatar')
}
}
}))
// Mock UserCredit component
vi.mock('@/components/common/UserCredit.vue', () => ({
default: {
name: 'UserCreditMock',
render() {
return h('div', 'Credit: 100')
}
}
}))
describe('CurrentUserPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const mountComponent = (): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(CurrentUserPopover, {
global: {
plugins: [i18n],
stubs: {
Divider: true,
Button: true
}
}
})
}
it('renders user information correctly', () => {
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Test User')
expect(wrapper.text()).toContain('test@example.com')
})
it('opens user settings and emits close event when settings button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the settings button (first one)
const buttons = wrapper.findAllComponents(Button)
const settingsButton = buttons[0]
// Click the settings button
await settingsButton.trigger('click')
// Verify showSettingsDialog was called with 'user'
expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the API pricing button (second one)
const buttons = wrapper.findAllComponents(Button)
const apiPricingButton = buttons[1]
// Click the API pricing button
await apiPricingButton.trigger('click')
// Verify window.open was called with the correct URL
expect(window.open).toHaveBeenCalledWith(
'https://docs.comfy.org/tutorials/api-nodes/pricing',
'_blank'
)
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the top-up button (last one)
const buttons = wrapper.findAllComponents(Button)
const topUpButton = buttons[buttons.length - 1]
// Click the top-up button
await topUpButton.trigger('click')
// Verify showTopUpCreditsDialog was called
expect(mockShowTopUpCreditsDialog).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
})
})

View File

@@ -72,20 +72,27 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useDialogService } from '@/services/dialogService'
const emit = defineEmits<{
close: []
}>()
const { userDisplayName, userEmail, userPhotoUrl } = useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
emit('close')
}
const handleTopUp = () => {
dialogService.showTopUpCreditsDialog()
emit('close')
}
const handleOpenApiPricing = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
emit('close')
}
onMounted(() => {

View File

@@ -1,396 +0,0 @@
import { ApiNodeCostRecord } from '@/types/apiNodeTypes'
/**
* API Node cost data sourced from pricing database
*
* GENERATED FILE - DO NOT EDIT DIRECTLY
* Generated from Price Run Range for each API Node CSV
*/
export const apiNodeCosts: ApiNodeCostRecord = {
FluxProCannyNode: {
vendor: 'Unknown',
nodeName: 'Flux 1: Canny Control Image',
pricingParams: '-',
pricePerRunRange: '$0.05',
displayPrice: '$0.05/Run'
},
FluxProDepthNode: {
vendor: 'Unknown',
nodeName: 'Flux 1: Depth Control Image',
pricingParams: '-',
pricePerRunRange: '$0.05',
displayPrice: '$0.05/Run'
},
FluxProExpandNode: {
vendor: 'Unknown',
nodeName: 'Flux 1: Expand Image',
pricingParams: '-',
pricePerRunRange: '$0.05',
rateDocumentation: 'https://docs.bfl.ml/pricing/',
displayPrice: '$0.05/Run'
},
FluxProFillNode: {
vendor: 'Unknown',
nodeName: 'Flux 1: Fill Image',
pricingParams: '-',
pricePerRunRange: '$0.05',
displayPrice: '$0.05/Run'
},
FluxProUltraImageNode: {
vendor: 'Unknown',
nodeName: 'Flux 1.1: [pro] Ultra Image',
pricingParams: '-',
pricePerRunRange: '$0.06',
displayPrice: '$0.06/Run'
},
IdeogramV1: {
vendor: 'Unknown',
nodeName: 'Ideogram V1',
pricingParams: '-',
pricePerRunRange: '$0.06',
rateDocumentation: 'https://about.ideogram.ai/api-pricing',
displayPrice: '$0.06/Run'
},
IdeogramV2: {
vendor: 'Unknown',
nodeName: 'Ideogram V2',
pricingParams: '-',
pricePerRunRange: '$0.08',
displayPrice: '$0.08/Run'
},
IdeogramV3: {
vendor: 'Unknown',
nodeName: 'Ideogram V3',
pricingParams: 'rendering_speed',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingCameraControlI2VNode: {
vendor: 'Unknown',
nodeName: 'Kling Image to Video (Camera Control)',
pricingParams: '-',
pricePerRunRange: '$0.49',
displayPrice: '$0.49/Run'
},
KlingCameraControlT2VNode: {
vendor: 'Unknown',
nodeName: 'Kling Text to Video (Camera Control)',
pricingParams: '-',
pricePerRunRange: '$0.14',
displayPrice: '$0.14/Run'
},
KlingDualCharacterVideoEffectNode: {
vendor: 'Unknown',
nodeName: 'Kling Dual Character Video Effects',
pricingParams: 'Priced the same as t2v based on mode, model, and duration.',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingImage2VideoNode: {
vendor: 'Unknown',
nodeName: 'Kling Image to Video',
pricingParams: 'Same as Text to Video',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingImageGenerationNode: {
vendor: 'Unknown',
nodeName: 'Kling Image Generation',
pricingParams: 'modality | model',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingLipSyncAudioToVideoNode: {
vendor: 'Unknown',
nodeName: 'Kling Lip Sync Video with Audio',
pricingParams: 'duration of input video',
pricePerRunRange: '$0.07',
displayPrice: '$0.07/Run'
},
KlingLipSyncTextToVideoNode: {
vendor: 'Unknown',
nodeName: 'Kling Lip Sync Video with Text',
pricingParams: 'duration of input video',
pricePerRunRange: '$0.07',
displayPrice: '$0.07/Run'
},
KlingSingleImageVideoEffectNode: {
vendor: 'Unknown',
nodeName: 'Kling Video Effects',
pricingParams: 'effect_scene',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingStartEndFrameNode: {
vendor: 'Unknown',
nodeName: 'Kling Start-End Frame to Video',
pricingParams: 'Same as text to video',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingTextToVideoNode: {
vendor: 'Unknown',
nodeName: 'Kling Text to Video',
pricingParams: 'model | duration | mode',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
KlingVideoExtendNode: {
vendor: 'Unknown',
nodeName: 'Kling Video Extend',
pricingParams: '-',
pricePerRunRange: '$0.28',
displayPrice: '$0.28/Run'
},
KlingVirtualTryOnNode: {
vendor: 'Unknown',
nodeName: 'Kling Virtual Try On',
pricingParams: '-',
pricePerRunRange: '$0.07',
displayPrice: '$0.07/Run'
},
LumaImageToVideoNode: {
vendor: 'Unknown',
nodeName: 'Luma Image to Video',
pricingParams: 'Same as Text to Video',
pricePerRunRange: 'dynamic',
rateDocumentation: 'https://lumalabs.ai/api/pricing',
displayPrice: 'Variable pricing'
},
LumaVideoNode: {
vendor: 'Unknown',
nodeName: 'Luma Text to Video',
pricingParams: 'model | resolution | duration',
pricePerRunRange: 'dynamic',
rateDocumentation: 'https://lumalabs.ai/api/pricing',
displayPrice: 'Variable pricing'
},
MinimaxImageToVideoNode: {
vendor: 'Unknown',
nodeName: 'MiniMax Image to Video',
pricingParams: '-',
pricePerRunRange: '$0.43',
rateDocumentation: 'https://www.minimax.io/price',
displayPrice: '$0.43/Run'
},
MinimaxTextToVideoNode: {
vendor: 'Unknown',
nodeName: 'MiniMax Text to Video',
pricingParams: '-',
pricePerRunRange: '$0.43',
rateDocumentation: 'https://www.minimax.io/price',
displayPrice: '$0.43/Run'
},
OpenAIDalle2: {
vendor: 'Unknown',
nodeName: 'dall-e-2',
pricingParams: 'size',
pricePerRunRange: 'dynamic',
rateDocumentation: 'https://platform.openai.com/docs/pricing',
displayPrice: 'Variable pricing'
},
OpenAIDalle3: {
vendor: 'Unknown',
nodeName: 'dall-e-3',
pricingParams: '1024×1024 | hd',
pricePerRunRange: '$0.08',
displayPrice: '$0.08/Run'
},
OpenAIGPTImage1: {
vendor: 'Unknown',
nodeName: 'gpt-image-1',
pricingParams: 'medium',
pricePerRunRange: '$[0.046 - 0.07]',
displayPrice: '$0.07/Run'
},
PikaImageToVideoNode2_2: {
vendor: 'Unknown',
nodeName: 'Pika Image to Video',
pricingParams: 'duration | resolution',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
PikaScenesV2_2: {
vendor: 'Unknown',
nodeName: 'Pika Scenes (Video Image Composition)',
pricingParams: 'duration | resolution',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
PikaStartEndFrameNode2_2: {
vendor: 'Unknown',
nodeName: 'Pika Start and End Frame to Video',
pricingParams: 'duration | resolution',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
PikaTextToVideoNode2_2: {
vendor: 'Unknown',
nodeName: 'Pika Text to Video',
pricingParams: 'duration | resolution',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
Pikadditions: {
vendor: 'Unknown',
nodeName: 'Pikadditions (Video Object Insertion)',
pricingParams: '-',
pricePerRunRange: '$0.3',
displayPrice: '$0.3/Run'
},
Pikaffects: {
vendor: 'Unknown',
nodeName: 'Pikaffects (Video Effects)',
pricingParams: '-',
pricePerRunRange: '0.45',
displayPrice: '$0.45/Run'
},
Pikaswaps: {
vendor: 'Unknown',
nodeName: 'Pika Swaps (Video Object Replacement)',
pricingParams: '-',
pricePerRunRange: '$0.3',
displayPrice: '$0.3/Run'
},
PixverseImageToVideoNode: {
vendor: 'Unknown',
nodeName: 'PixVerse Image to Video',
pricingParams: 'same as text to video',
pricePerRunRange: '$0.9',
displayPrice: '$0.9/Run'
},
PixverseTextToVideoNode: {
vendor: 'Unknown',
nodeName: 'PixVerse Text to Video',
pricingParams: 'duration | quality | motion_mode',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
PixverseTransitionVideoNode: {
vendor: 'Unknown',
nodeName: 'PixVerse Transition Video',
pricingParams: 'same as text to video',
pricePerRunRange: '$0.9',
displayPrice: '$0.9/Run'
},
RecraftCrispUpscaleNode: {
vendor: 'Unknown',
nodeName: 'Recraft Crisp Upscale Image',
pricingParams: '-',
pricePerRunRange: '$0.004',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.004/Run'
},
RecraftImageInpaintingNode: {
vendor: 'Unknown',
nodeName: 'Recraft Image Inpainting',
pricingParams: 'n',
pricePerRunRange: '$$0.04 x n',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$$0.04 x n/Run'
},
RecraftImageToImageNode: {
vendor: 'Unknown',
nodeName: 'Recraft Image to Image',
pricingParams: 'n',
pricePerRunRange: '$0.04 x n',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.04 x n/Run'
},
RecraftRemoveBackgroundNode: {
vendor: 'Unknown',
nodeName: 'Recraft Remove Background',
pricingParams: '-',
pricePerRunRange: '$0.01',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.01/Run'
},
RecraftReplaceBackgroundNode: {
vendor: 'Unknown',
nodeName: 'Recraft Replace Background',
pricingParams: 'n',
pricePerRunRange: '$0.04',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.04/Run'
},
RecraftTextToImageNode: {
vendor: 'Unknown',
nodeName: 'Recraft Text to Image',
pricingParams: 'model | n',
pricePerRunRange: '$0.04 x n',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.04 x n/Run'
},
RecraftTextToVectorNode: {
vendor: 'Unknown',
nodeName: 'Recraft Text to Vector',
pricingParams: 'model | n',
pricePerRunRange: '$0.08 x n',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.08 x n/Run'
},
RecraftVectorizeImageNode: {
vendor: 'Unknown',
nodeName: 'Recraft Vectorize Image',
pricingParams: '-',
pricePerRunRange: '$0.01',
rateDocumentation: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.01/Run'
},
StabilityStableImageSD_3_5Node: {
vendor: 'Unknown',
nodeName: 'Stability AI Stable Diffusion 3.5 Image',
pricingParams: 'model',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing'
},
StabilityStableImageUltraNode: {
vendor: 'Unknown',
nodeName: 'Stability AI Stable Image Ultra',
pricingParams: '-',
pricePerRunRange: '$0.08',
displayPrice: '$0.08/Run'
},
StabilityUpscaleConservativeNode: {
vendor: 'Unknown',
nodeName: 'Stability AI Upscale Conservative',
pricingParams: '-',
pricePerRunRange: '$0.25',
displayPrice: '$0.25/Run'
},
StabilityUpscaleCreativeNode: {
vendor: 'Unknown',
nodeName: 'Stability AI Upscale Creative',
pricingParams: '-',
pricePerRunRange: '$0.25',
displayPrice: '$0.25/Run'
},
StabilityUpscaleFastNode: {
vendor: 'Unknown',
nodeName: 'Stability AI Upscale Fast',
pricingParams: '-',
pricePerRunRange: '$0.01',
displayPrice: '$0.01/Run'
},
VeoVideoGenerationNode: {
vendor: 'Unknown',
nodeName: 'Google Veo2 Video Generation',
pricingParams: '10s',
pricePerRunRange: '$5.0',
rateDocumentation:
'https://cloud.google.com/vertex-ai/generative-ai/pricing',
displayPrice: '$5.0/Run'
}
}
/**
* Get the display price for a node
* Returns a default value if the node isn't found
*/
export function getNodeDisplayPrice(
nodeName: string,
defaultPrice = '0.02/Run (approx)'
): string {
return apiNodeCosts[nodeName]?.displayPrice || defaultPrice
}

View File

@@ -13,8 +13,6 @@ import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { NodeBadgeMode } from '@/types/nodeSource'
import { useNodePricing } from './useNodePricing'
/**
* Add LGraphBadge to LGraphNode based on settings.
*
@@ -59,8 +57,6 @@ export const useNodeBadge = () => {
}
onMounted(() => {
const nodePricing = useNodePricing()
extensionStore.registerExtension({
name: 'Comfy.NodeBadge',
nodeCreated(node: LGraphNode) {
@@ -99,12 +95,9 @@ export const useNodeBadge = () => {
node.badges.push(() => badge.value)
if (node.constructor.nodeData?.api_node) {
// Get price from our mapping service
const price = nodePricing.getNodePriceDisplay(node)
const creditsBadge = computed(() => {
return new LGraphBadge({
text: price ?? '',
text: '',
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
@@ -115,7 +108,9 @@ export const useNodeBadge = () => {
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: '#8D6932'
bgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_BG_COLOR
})
})

View File

@@ -16,9 +16,6 @@ export function useNodeChatHistory(
) {
const chatHistoryWidget = useChatHistoryWidget(options)
const findChatHistoryWidget = (node: LGraphNode) =>
node.widgets?.find((w) => w.name === CHAT_HISTORY_WIDGET_NAME)
const addChatHistoryWidget = (node: LGraphNode) =>
chatHistoryWidget(node, {
name: CHAT_HISTORY_WIDGET_NAME,
@@ -30,9 +27,11 @@ export function useNodeChatHistory(
* @param node The graph node to show the chat history for
*/
function showChatHistory(node: LGraphNode) {
if (!findChatHistoryWidget(node)) {
addChatHistoryWidget(node)
}
// First remove any existing widget
removeChatHistory(node)
// Then add the widget with new history
addChatHistoryWidget(node)
node.setDirtyCanvas?.(true)
}

View File

@@ -1,103 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
// Direct mapping of node names to prices
export const NODE_PRICES: Record<string, string> = {
// OpenAI models
OpenAIDalle2: '$0.02/Run',
OpenAIDalle3: '$0.08/Run',
OpenAIGPTImage1: '$0.07/Run',
// Ideogram models
IdeogramV1: '$0.06/Run',
IdeogramV2: '$0.08/Run',
IdeogramV3: 'Variable pricing',
// Minimax models
MinimaxTextToVideoNode: '$0.43/Run',
MinimaxImageToVideoNode: '$0.43/Run',
// Google Veo
VeoVideoGenerationNode: '$5.0/Run',
// Kling models
KlingTextToVideoNode: 'Variable pricing',
KlingImage2VideoNode: 'Variable pricing',
KlingCameraControlI2VNode: '$0.49/Run',
KlingCameraControlT2VNode: '$0.14/Run',
KlingStartEndFrameNode: 'Variable pricing',
KlingVideoExtendNode: '$0.28/Run',
KlingLipSyncAudioToVideoNode: '$0.07/Run',
KlingLipSyncTextToVideoNode: '$0.07/Run',
KlingVirtualTryOnNode: '$0.07/Run',
KlingImageGenerationNode: 'Variable pricing',
KlingSingleImageVideoEffectNode: 'Variable pricing',
KlingDualCharacterVideoEffectNode: 'Variable pricing',
// Flux Pro models
FluxProUltraImageNode: '$0.06/Run',
FluxProExpandNode: '$0.05/Run',
FluxProFillNode: '$0.05/Run',
FluxProCannyNode: '$0.05/Run',
FluxProDepthNode: '$0.05/Run',
// Luma models
LumaVideoNode: 'Variable pricing',
LumaImageToVideoNode: 'Variable pricing',
LumaImageNode: 'Variable pricing',
LumaImageModifyNode: 'Variable pricing',
// Recraft models
RecraftTextToImageNode: '$0.04/Run',
RecraftImageToImageNode: '$0.04/Run',
RecraftImageInpaintingNode: '$0.04/Run',
RecraftTextToVectorNode: '$0.08/Run',
RecraftVectorizeImageNode: '$0.01/Run',
RecraftRemoveBackgroundNode: '$0.01/Run',
RecraftReplaceBackgroundNode: '$0.04/Run',
RecraftCrispUpscaleNode: '$0.004/Run',
RecraftCreativeUpscaleNode: '$0.004/Run',
// Pixverse models
PixverseTextToVideoNode: '$0.9/Run',
PixverseImageToVideoNode: '$0.9/Run',
PixverseTransitionVideoNode: '$0.9/Run',
// Stability models
StabilityStableImageUltraNode: '$0.08/Run',
StabilityStableImageSD_3_5Node: 'Variable pricing',
StabilityUpscaleConservativeNode: '$0.25/Run',
StabilityUpscaleCreativeNode: '$0.25/Run',
StabilityUpscaleFastNode: '$0.01/Run',
// Pika models
PikaImageToVideoNode2_2: 'Variable pricing',
PikaTextToVideoNode2_2: 'Variable pricing',
PikaScenesV2_2: 'Variable pricing',
PikaStartEndFrameNode2_2: 'Variable pricing',
Pikadditions: '$0.3/Run',
Pikaswaps: '$0.3/Run',
Pikaffects: '$0.45/Run'
}
/**
* Simple utility function to get the price for a node
* Returns a formatted price string or default value if the node isn't found
*/
export function getNodePrice(
node: LGraphNode,
defaultPrice = '0.02/Run (approx)'
): string {
if (!node.constructor.nodeData?.api_node) {
return ''
}
return NODE_PRICES[node.constructor.name] || defaultPrice
}
/**
* Composable to get node pricing information for API nodes
*/
export const useNodePricing = () => {
/**
* Get the price display for a node
*/
const getNodePriceDisplay = (node: LGraphNode): string => {
if (!node.constructor.nodeData?.api_node) {
return ''
}
return NODE_PRICES[node.constructor.name] || '0.02/Run (approx)'
}
return {
getNodePriceDisplay
}
}

View File

@@ -1,63 +0,0 @@
const apiNodeNames = [
'IdeogramV1',
'IdeogramV2',
'IdeogramV3',
'MinimaxTextToVideoNode',
'MinimaxImageToVideoNode',
'VeoVideoGenerationNode',
'KlingCameraControls',
'KlingTextToVideoNode',
'KlingImage2VideoNode',
'KlingCameraControlI2VNode',
'KlingCameraControlT2VNode',
'KlingStartEndFrameNode',
'KlingVideoExtendNode',
'KlingLipSyncAudioToVideoNode',
'KlingLipSyncTextToVideoNode',
'KlingVirtualTryOnNode',
'KlingImageGenerationNode',
'KlingSingleImageVideoEffectNode',
'KlingDualCharacterVideoEffectNode',
'FluxProUltraImageNode',
'FluxProExpandNode',
'FluxProFillNode',
'FluxProCannyNode',
'FluxProDepthNode',
'LumaImageNode',
'LumaImageModifyNode',
'LumaVideoNode',
'LumaImageToVideoNode',
'LumaReferenceNode',
'LumaConceptsNode',
'RecraftTextToImageNode',
'RecraftImageToImageNode',
'RecraftImageInpaintingNode',
'RecraftTextToVectorNode',
'RecraftVectorizeImageNode',
'RecraftRemoveBackgroundNode',
'RecraftReplaceBackgroundNode',
'RecraftCrispUpscaleNode',
'RecraftCreativeUpscaleNode',
'RecraftStyleV3RealisticImage',
'RecraftStyleV3DigitalIllustration',
'RecraftStyleV3LogoRaster',
'RecraftStyleV3InfiniteStyleLibrary',
'RecraftColorRGB',
'RecraftControls',
'PixverseTextToVideoNode',
'PixverseImageToVideoNode',
'PixverseTransitionVideoNode',
'PixverseTemplateNode',
'StabilityStableImageUltraNode',
'StabilityStableImageSD_3_5Node',
'StabilityUpscaleConservativeNode',
'StabilityUpscaleCreativeNode',
'StabilityUpscaleFastNode',
'PikaImageToVideoNode2_2',
'PikaTextToVideoNode2_2',
'PikaScenesV2_2',
'Pikadditions',
'Pikaswaps',
'Pikaffects',
'PikaStartEndFrameNode2_2'
]

View File

@@ -1,3 +1,4 @@
import { searchClient as algoliasearch } from '@algolia/client-search'
import QuickLRU from '@alloc/quick-lru'
import type {
BaseSearchParamsWithoutQuery,
@@ -5,7 +6,6 @@ import type {
SearchQuery,
SearchResponse
} from 'algoliasearch/dist/lite/browser'
import { liteClient as algoliasearch } from 'algoliasearch/dist/lite/builds/browser'
import { omit } from 'lodash'
import { components } from '@/types/comfyRegistryTypes'
@@ -124,7 +124,15 @@ export const useAlgoliaSearchService = (
maxCacheSize = DEFAULT_MAX_CACHE_SIZE,
minCharsForSuggestions = DEFAULT_MIN_CHARS_FOR_SUGGESTIONS
} = options
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__, {
hosts: [
{
url: 'https://search.comfy.org',
accept: 'read',
protocol: 'https'
}
]
})
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
maxSize: maxCacheSize
})

View File

@@ -2,14 +2,3 @@ export interface ApiNodeCost {
name: string
cost: number
}
export interface ApiNodeCostData {
vendor: string
nodeName: string
pricingParams: string
pricePerRunRange: string
displayPrice: string
rateDocumentation?: string
}
export type ApiNodeCostRecord = Record<string, ApiNodeCostData>

45
tests-ui/README.md Normal file
View File

@@ -0,0 +1,45 @@
# ComfyUI Frontend Testing Guide
This guide provides an overview of testing approaches used in the ComfyUI Frontend codebase. These guides are meant to document any particularities or nuances of writing tests in this codebase, rather than being a comprehensive guide to testing in general. By reading these guides first, you may save yourself some time when encountering issues.
## Testing Documentation
Documentation for unit tests is organized into three guides:
- [Component Testing](./component-testing.md) - How to test Vue components
- [Unit Testing](./unit-testing.md) - How to test utility functions, composables, and other non-component code
- [Store Testing](./store-testing.md) - How to test Pinia stores specifically
## Testing Structure
The ComfyUI Frontend project uses a mixed approach to unit test organization:
- **Component Tests**: Located directly alongside their components with a `.spec.ts` extension
- **Unit Tests**: Located in the `tests-ui/tests/` directory
- **Store Tests**: Located in the `tests-ui/tests/store/` directory
- **Browser Tests**: These are located in the `browser_tests/` directory. There is a dedicated README in the `browser_tests/` directory, so it will not be covered here.
## Test Frameworks and Libraries
Our tests use the following frameworks and libraries:
- [Vitest](https://vitest.dev/) - Test runner and assertion library
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities
- [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing
## Getting Started
To run the tests locally:
```bash
# Run unit tests
npm run test:unit
# Run unit tests in watch mode
npm run test:unit:dev
# Run component tests with browser-native environment
npm run test:component
```
Refer to the specific guides for more detailed information on each testing type.

View File

@@ -0,0 +1,370 @@
# Component Testing Guide
This guide covers patterns and examples for testing Vue components in the ComfyUI Frontend codebase.
## Table of Contents
1. [Basic Component Testing](#basic-component-testing)
2. [PrimeVue Components Testing](#primevue-components-testing)
3. [Tooltip Directives](#tooltip-directives)
4. [Component Events Testing](#component-events-testing)
5. [User Interaction Testing](#user-interaction-testing)
6. [Asynchronous Component Testing](#asynchronous-component-testing)
7. [Working with Vue Reactivity](#working-with-vue-reactivity)
## Basic Component Testing
Basic approach to testing a component's rendering and structure:
```typescript
// Example from: src/components/sidebar/SidebarIcon.spec.ts
import { mount } from '@vue/test-utils'
import SidebarIcon from './SidebarIcon.vue'
describe('SidebarIcon', () => {
const exampleProps = {
icon: 'pi pi-cog',
selected: false
}
const mountSidebarIcon = (props, options = {}) => {
return mount(SidebarIcon, {
props: { ...exampleProps, ...props },
...options
})
}
it('renders label', () => {
const wrapper = mountSidebarIcon({})
expect(wrapper.find('.p-button.p-component').exists()).toBe(true)
expect(wrapper.find('.p-button-label').exists()).toBe(true)
})
})
```
## PrimeVue Components Testing
Setting up and testing PrimeVue components:
```typescript
// Example from: src/components/common/ColorCustomizationSelector.spec.ts
import { mount } from '@vue/test-utils'
import ColorPicker from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import SelectButton from 'primevue/selectbutton'
import { createApp } from 'vue'
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
describe('ColorCustomizationSelector', () => {
beforeEach(() => {
// Setup PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props = {}) => {
return mount(ColorCustomizationSelector, {
global: {
plugins: [PrimeVue],
components: { SelectButton, ColorPicker }
},
props: {
modelValue: null,
colorOptions: [
{ name: 'Blue', value: '#0d6efd' },
{ name: 'Green', value: '#28a745' }
],
...props
}
})
}
it('initializes with predefined color when provided', async () => {
const wrapper = mountComponent({
modelValue: '#0d6efd'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: 'Blue',
value: '#0d6efd'
})
})
})
```
## Tooltip Directives
Testing components with tooltip directives:
```typescript
// Example from: src/components/sidebar/SidebarIcon.spec.ts
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
describe('SidebarIcon with tooltip', () => {
it('shows tooltip on hover', async () => {
const tooltipShowDelay = 300
const tooltipText = 'Settings'
const wrapper = mount(SidebarIcon, {
global: {
plugins: [PrimeVue],
directives: { tooltip: Tooltip }
},
props: {
icon: 'pi pi-cog',
selected: false,
tooltip: tooltipText
}
})
// Hover over the icon
await wrapper.trigger('mouseenter')
await new Promise((resolve) => setTimeout(resolve, tooltipShowDelay + 16))
const tooltipElAfterHover = document.querySelector('[role="tooltip"]')
expect(tooltipElAfterHover).not.toBeNull()
})
it('sets aria-label attribute when tooltip is provided', () => {
const tooltipText = 'Settings'
const wrapper = mount(SidebarIcon, {
global: {
plugins: [PrimeVue],
directives: { tooltip: Tooltip }
},
props: {
icon: 'pi pi-cog',
selected: false,
tooltip: tooltipText
}
})
expect(wrapper.attributes('aria-label')).toEqual(tooltipText)
})
})
```
## Component Events Testing
Testing component events:
```typescript
// Example from: src/components/common/ColorCustomizationSelector.spec.ts
it('emits update when predefined color is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
await selectButton.setValue(colorOptions[0])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
})
it('emits update when custom color is changed', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
// Change custom color
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.setValue('ff0000')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
})
```
## User Interaction Testing
Testing user interactions:
```typescript
// Example from: src/components/common/EditableText.spec.ts
describe('EditableText', () => {
it('switches to edit mode on click', async () => {
const wrapper = mount(EditableText, {
props: {
modelValue: 'Initial Text',
editable: true
}
})
// Initially in view mode
expect(wrapper.find('input').exists()).toBe(false)
// Click to edit
await wrapper.find('.editable-text').trigger('click')
// Should switch to edit mode
expect(wrapper.find('input').exists()).toBe(true)
expect(wrapper.find('input').element.value).toBe('Initial Text')
})
it('saves changes on enter key press', async () => {
const wrapper = mount(EditableText, {
props: {
modelValue: 'Initial Text',
editable: true
}
})
// Switch to edit mode
await wrapper.find('.editable-text').trigger('click')
// Change input value
const input = wrapper.find('input')
await input.setValue('New Text')
// Press enter to save
await input.trigger('keydown.enter')
// Check if event was emitted with new value
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['New Text'])
// Should switch back to view mode
expect(wrapper.find('input').exists()).toBe(false)
})
})
```
## Asynchronous Component Testing
Testing components with async behavior:
```typescript
// Example from: src/components/dialog/content/manager/PackVersionSelectorPopover.test.ts
import { nextTick } from 'vue'
it('shows dropdown options when clicked', async () => {
const wrapper = mount(PackVersionSelectorPopover, {
props: {
versions: ['1.0.0', '1.1.0', '2.0.0'],
selectedVersion: '1.1.0'
}
})
// Initially dropdown should be hidden
expect(wrapper.find('.p-dropdown-panel').isVisible()).toBe(false)
// Click dropdown
await wrapper.find('.p-dropdown').trigger('click')
await nextTick() // Wait for Vue to update the DOM
// Dropdown should be visible now
expect(wrapper.find('.p-dropdown-panel').isVisible()).toBe(true)
// Options should match the provided versions
const options = wrapper.findAll('.p-dropdown-item')
expect(options.length).toBe(3)
expect(options[0].text()).toBe('1.0.0')
expect(options[1].text()).toBe('1.1.0')
expect(options[2].text()).toBe('2.0.0')
})
```
## Working with Vue Reactivity
Testing components with complex reactive behavior can be challenging. Here are patterns to help manage reactivity issues in tests:
### Helper Function for Waiting on Reactivity
Use a helper function to wait for both promises and the Vue reactivity cycle:
```typescript
// Example from: src/components/dialog/content/manager/PackVersionSelectorPopover.test.ts
const waitForPromises = async () => {
// Wait for any promises in the microtask queue
await new Promise((resolve) => setTimeout(resolve, 16))
// Wait for Vue to update the DOM
await nextTick()
}
it('fetches versions on mount', async () => {
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
mountComponent()
await waitForPromises() // Wait for async operations and reactivity
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
})
```
### Testing Components with Async Lifecycle Hooks
When components use `onMounted` or other lifecycle hooks with async operations:
```typescript
it('shows loading state while fetching versions', async () => {
// Delay the promise resolution
mockGetPackVersions.mockImplementationOnce(
() => new Promise((resolve) =>
setTimeout(() => resolve(defaultMockVersions), 1000)
)
)
const wrapper = mountComponent()
// Check loading state before promises resolve
expect(wrapper.text()).toContain('Loading versions...')
})
```
### Testing Prop Changes
Test components' reactivity to prop changes:
```typescript
// Example from: src/components/dialog/content/manager/PackVersionSelectorPopover.test.ts
it('is reactive to nodePack prop changes', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Set up the mock for the second fetch after prop change
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Update the nodePack prop
const newNodePack = { ...mockNodePack, id: 'new-test-pack' }
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
})
```
### Handling Computed Properties
Testing components with computed properties that depend on async data:
```typescript
it('displays special options and version options in the listbox', async () => {
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises() // Wait for data fetching and computed property updates
const listbox = wrapper.findComponent(Listbox)
const options = listbox.props('options')!
// Now options should be populated through computed properties
expect(options.length).toBe(defaultMockVersions.length + 2)
})
```
### Common Reactivity Pitfalls
1. **Not waiting for all promises**: Ensure you wait for both component promises and Vue's reactivity system
2. **Timing issues with component mounting**: Components might not be fully mounted when assertions run
3. **Async lifecycle hooks**: Components using async `onMounted` require careful handling
4. **PrimeVue components**: PrimeVue components often have their own internal state and reactivity that needs time to update
5. **Computed properties depending on async data**: Always ensure async data is loaded before testing computed properties
By using the `waitForPromises` helper and being mindful of these patterns, you can write more robust tests for components with complex reactivity.

280
tests-ui/store-testing.md Normal file
View File

@@ -0,0 +1,280 @@
# Pinia Store Testing Guide
This guide covers patterns and examples for testing Pinia stores in the ComfyUI Frontend codebase.
## Table of Contents
1. [Setting Up Store Tests](#setting-up-store-tests)
2. [Testing Store State](#testing-store-state)
3. [Testing Store Actions](#testing-store-actions)
4. [Testing Store Getters](#testing-store-getters)
5. [Mocking Dependencies in Stores](#mocking-dependencies-in-stores)
6. [Testing Store Watchers](#testing-store-watchers)
7. [Testing Store Integration](#testing-store-integration)
## Setting Up Store Tests
Basic setup for testing Pinia stores:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowStore } from '@/stores/workflowStore'
describe('useWorkflowStore', () => {
let store: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
// Create a fresh pinia and activate it for each test
setActivePinia(createPinia())
// Initialize the store
store = useWorkflowStore()
// Clear any mocks
vi.clearAllMocks()
})
it('should initialize with default state', () => {
expect(store.workflows).toEqual([])
expect(store.activeWorkflow).toBeUndefined()
expect(store.openWorkflows).toEqual([])
})
})
```
## Testing Store State
Testing store state changes:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
it('should create a temporary workflow with a unique path', () => {
const workflow = store.createTemporary()
expect(workflow.path).toBe('workflows/Unsaved Workflow.json')
const workflow2 = store.createTemporary()
expect(workflow2.path).toBe('workflows/Unsaved Workflow (2).json')
})
it('should create a temporary workflow not clashing with persisted workflows', async () => {
await syncRemoteWorkflows(['a.json'])
const workflow = store.createTemporary('a.json')
expect(workflow.path).toBe('workflows/a (2).json')
})
```
## Testing Store Actions
Testing store actions:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
describe('openWorkflow', () => {
it('should load and open a temporary workflow', async () => {
// Create a test workflow
const workflow = store.createTemporary('test.json')
const mockWorkflowData = { nodes: [], links: [] }
// Mock the load response
vi.spyOn(workflow, 'load').mockImplementation(async () => {
workflow.changeTracker = { activeState: mockWorkflowData } as any
return workflow as LoadedComfyWorkflow
})
// Open the workflow
await store.openWorkflow(workflow)
// Verify the workflow is now active
expect(store.activeWorkflow?.path).toBe(workflow.path)
// Verify the workflow is in the open workflows list
expect(store.isOpen(workflow)).toBe(true)
})
it('should not reload an already active workflow', async () => {
const workflow = await store.createTemporary('test.json').load()
vi.spyOn(workflow, 'load')
// Set as active workflow
store.activeWorkflow = workflow
await store.openWorkflow(workflow)
// Verify load was not called
expect(workflow.load).not.toHaveBeenCalled()
})
})
```
## Testing Store Getters
Testing store getters:
```typescript
// Example from: tests-ui/tests/store/modelStore.test.ts
describe('getters', () => {
beforeEach(() => {
setActivePinia(createPinia())
store = useModelStore()
// Set up test data
store.models = {
checkpoints: [
{ name: 'model1.safetensors', path: 'models/checkpoints/model1.safetensors' },
{ name: 'model2.ckpt', path: 'models/checkpoints/model2.ckpt' }
],
loras: [
{ name: 'lora1.safetensors', path: 'models/loras/lora1.safetensors' }
]
}
// Mock API
vi.mocked(api.getModelInfo).mockImplementation(async (modelName) => {
if (modelName.includes('model1')) {
return { info: { resolution: 768 } }
}
return { info: { resolution: 512 } }
})
})
it('should return models grouped by type', () => {
expect(store.modelsByType.checkpoints.length).toBe(2)
expect(store.modelsByType.loras.length).toBe(1)
})
it('should filter models by name', () => {
store.searchTerm = 'model1'
expect(store.filteredModels.checkpoints.length).toBe(1)
expect(store.filteredModels.checkpoints[0].name).toBe('model1.safetensors')
})
})
```
## Mocking Dependencies in Stores
Mocking API and other dependencies:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
// 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()
}
}))
// Mock comfyApp globally for the store setup
vi.mock('@/scripts/app', () => ({
app: {
canvas: null // Start with canvas potentially undefined or null
}
}))
describe('syncWorkflows', () => {
const syncRemoteWorkflows = async (filenames: string[]) => {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue(
filenames.map((filename) => ({
path: filename,
modified: new Date().getTime(),
size: 1 // size !== -1 for remote workflows
}))
)
return await store.syncWorkflows()
}
it('should sync workflows', async () => {
await syncRemoteWorkflows(['a.json', 'b.json'])
expect(store.workflows.length).toBe(2)
})
})
```
## Testing Store Watchers
Testing store watchers and reactive behavior:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { nextTick } from 'vue'
describe('Subgraphs', () => {
it('should update automatically when activeWorkflow changes', async () => {
// Arrange: Set initial canvas state
const initialSubgraph = {
name: 'Initial Subgraph',
pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }],
isRootGraph: false
}
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph as any
// Trigger initial update
store.updateActiveGraph()
await nextTick()
// Verify initial state
expect(store.isSubgraphActive).toBe(true)
expect(store.subgraphNamePath).toEqual(['Initial Subgraph'])
// Act: Change the active workflow and canvas state
const workflow2 = store.createTemporary('workflow2.json')
vi.spyOn(workflow2, 'load').mockImplementation(async () => {
workflow2.changeTracker = { activeState: {} } as any
workflow2.originalContent = '{}'
workflow2.content = '{}'
return workflow2 as LoadedComfyWorkflow
})
// Change canvas state
vi.mocked(comfyApp.canvas).subgraph = undefined
await store.openWorkflow(workflow2)
await nextTick() // Allow watcher to trigger
// Assert: Check state was updated by the watcher
expect(store.isSubgraphActive).toBe(false)
expect(store.subgraphNamePath).toEqual([])
})
})
```
## Testing Store Integration
Testing store integration with other parts of the application:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
describe('renameWorkflow', () => {
it('should rename workflow and update bookmarks', async () => {
const workflow = store.createTemporary('dir/test.json')
const bookmarkStore = useWorkflowBookmarkStore()
// Set up initial bookmark
expect(workflow.path).toBe('workflows/dir/test.json')
await bookmarkStore.setBookmarked(workflow.path, true)
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(true)
// Mock super.rename
vi.spyOn(Object.getPrototypeOf(workflow), 'rename').mockImplementation(
async function (this: any, newPath: string) {
this.path = newPath
return this
} as any
)
// Perform rename
const newPath = 'workflows/dir/renamed.json'
await store.renameWorkflow(workflow, newPath)
// Check that bookmark was transferred
expect(bookmarkStore.isBookmarked(newPath)).toBe(true)
expect(bookmarkStore.isBookmarked('workflows/dir/test.json')).toBe(false)
})
})
```

View File

@@ -0,0 +1,95 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { edit: 'Edit' },
chatHistory: {
cancelEdit: 'Cancel edit',
cancelEditTooltip: 'Cancel edit'
}
}
}
})
vi.mock('@/components/graph/widgets/chatHistory/CopyButton.vue', () => ({
default: {
name: 'CopyButton',
template: '<div class="mock-copy-button"></div>',
props: ['text']
}
}))
vi.mock('@/components/graph/widgets/chatHistory/ResponseBlurb.vue', () => ({
default: {
name: 'ResponseBlurb',
template: '<div class="mock-response-blurb"><slot /></div>',
props: ['text']
}
}))
describe('ChatHistoryWidget.vue', () => {
const mockHistory = JSON.stringify([
{ prompt: 'Test prompt', response: 'Test response', response_id: '123' }
])
const mountWidget = (props: { history: string; widget?: any }) => {
return mount(ChatHistoryWidget, {
props,
global: {
plugins: [i18n],
stubs: {
Button: {
template: '<button><slot /></button>',
props: ['icon', 'aria-label']
},
ScrollPanel: { template: '<div><slot /></div>' }
}
}
})
}
it('renders chat history correctly', () => {
const wrapper = mountWidget({ history: mockHistory })
expect(wrapper.text()).toContain('Test prompt')
expect(wrapper.text()).toContain('Test response')
})
it('handles empty history', () => {
const wrapper = mountWidget({ history: '[]' })
expect(wrapper.find('.mb-4').exists()).toBe(false)
})
it('edits previous prompts', () => {
const mockWidget = {
node: { widgets: [{ name: 'prompt', value: '' }] }
}
const wrapper = mountWidget({ history: mockHistory, widget: mockWidget })
const vm = wrapper.vm as any
vm.handleEdit(0)
expect(mockWidget.node.widgets[0].value).toContain('Test prompt')
expect(mockWidget.node.widgets[0].value).toContain('starting_point_id')
})
it('cancels editing correctly', () => {
const mockWidget = {
node: { widgets: [{ name: 'prompt', value: 'Original value' }] }
}
const wrapper = mountWidget({ history: mockHistory, widget: mockWidget })
const vm = wrapper.vm as any
vm.handleEdit(0)
vm.handleCancelEdit()
expect(mockWidget.node.widgets[0].value).toBe('Original value')
})
})

View File

@@ -0,0 +1,63 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeChatHistory } from '@/composables/node/useNodeChatHistory'
vi.mock('@/composables/widgets/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
}
}
}))
// Mock LGraphNode type
type MockNode = {
widgets: Array<{ name: string; type: string }>
setDirtyCanvas: ReturnType<typeof vi.fn>
addCustomWidget: ReturnType<typeof vi.fn>
[key: string]: any
}
describe('useNodeChatHistory', () => {
const mockNode = {
widgets: [],
setDirtyCanvas: vi.fn(),
addCustomWidget: vi.fn()
} as unknown as LGraphNode & MockNode
beforeEach(() => {
mockNode.widgets = []
mockNode.setDirtyCanvas.mockClear()
mockNode.addCustomWidget.mockClear()
})
it('adds chat history widget to node', () => {
const { showChatHistory } = useNodeChatHistory()
showChatHistory(mockNode)
expect(mockNode.widgets.length).toBe(1)
expect(mockNode.widgets[0].name).toBe('$$node-chat-history')
expect(mockNode.setDirtyCanvas).toHaveBeenCalled()
})
it('removes chat history widget from node', () => {
const { showChatHistory, removeChatHistory } = useNodeChatHistory()
showChatHistory(mockNode)
expect(mockNode.widgets.length).toBe(1)
removeChatHistory(mockNode)
expect(mockNode.widgets.length).toBe(0)
})
})

View File

@@ -0,0 +1,82 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useExecutionStore } from '@/stores/executionStore'
// Remove any previous global types
declare global {
// Empty interface to override any previous declarations
interface Window {}
}
const mockShowChatHistory = vi.fn()
vi.mock('@/composables/node/useNodeChatHistory', () => ({
useNodeChatHistory: () => ({
showChatHistory: mockShowChatHistory
})
}))
vi.mock('@/composables/node/useNodeProgressText', () => ({
useNodeProgressText: () => ({
showTextPreview: vi.fn()
})
}))
// Create a local mock instead of using global to avoid conflicts
const mockApp = {
graph: {
getNodeById: vi.fn()
}
}
describe('executionStore - display_component handling', () => {
function createDisplayComponentEvent(
nodeId: string,
component = 'ChatHistoryWidget'
) {
return new CustomEvent('display_component', {
detail: {
node_id: nodeId,
component,
props: {
history: JSON.stringify([{ prompt: 'Test', response: 'Response' }])
}
}
})
}
function handleDisplayComponentMessage(event: CustomEvent) {
const { node_id, component } = event.detail
const node = mockApp.graph.getNodeById(node_id)
if (node && component === 'ChatHistoryWidget') {
mockShowChatHistory(node)
}
}
beforeEach(() => {
setActivePinia(createPinia())
useExecutionStore()
vi.clearAllMocks()
})
it('handles ChatHistoryWidget display_component messages', () => {
const mockNode = { id: '123' }
mockApp.graph.getNodeById.mockReturnValue(mockNode)
const event = createDisplayComponentEvent('123')
handleDisplayComponentMessage(event)
expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('123')
expect(mockShowChatHistory).toHaveBeenCalledWith(mockNode)
})
it('does nothing if node is not found', () => {
mockApp.graph.getNodeById.mockReturnValue(null)
const event = createDisplayComponentEvent('non-existent')
handleDisplayComponentMessage(event)
expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('non-existent')
expect(mockShowChatHistory).not.toHaveBeenCalled()
})
})

251
tests-ui/unit-testing.md Normal file
View File

@@ -0,0 +1,251 @@
# Unit Testing Guide
This guide covers patterns and examples for unit testing utilities, composables, and other non-component code in the ComfyUI Frontend codebase.
## Table of Contents
1. [Testing Vue Composables with Reactivity](#testing-vue-composables-with-reactivity)
2. [Working with LiteGraph and Nodes](#working-with-litegraph-and-nodes)
3. [Working with Workflow JSON Files](#working-with-workflow-json-files)
4. [Mocking the API Object](#mocking-the-api-object)
5. [Mocking Lodash Functions](#mocking-lodash-functions)
6. [Testing with Debounce and Throttle](#testing-with-debounce-and-throttle)
7. [Mocking Node Definitions](#mocking-node-definitions)
## Testing Vue Composables with Reactivity
Testing Vue composables requires handling reactivity correctly:
```typescript
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useServerLogs } from '@/composables/useServerLogs'
// Mock dependencies
vi.mock('@/scripts/api', () => ({
api: {
subscribeLogs: vi.fn()
}
}))
describe('useServerLogs', () => {
it('should update reactive logs when receiving events', async () => {
const { logs, startListening } = useServerLogs()
await startListening()
// Simulate log event handler being called
const mockHandler = vi.mocked(useEventListener).mock.calls[0][2]
mockHandler(new CustomEvent('logs', {
detail: {
type: 'logs',
entries: [{ m: 'Log message' }]
}
}))
// Must wait for Vue reactivity to update
await nextTick()
expect(logs.value).toEqual(['Log message'])
})
})
```
## Working with LiteGraph and Nodes
Testing LiteGraph-related functionality:
```typescript
// Example from: tests-ui/tests/litegraph.test.ts
import { LGraph, LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import { describe, expect, it } from 'vitest'
// Create dummy node for testing
class DummyNode extends LGraphNode {
constructor() {
super('dummy')
}
}
describe('LGraph', () => {
it('should serialize graph nodes', async () => {
// Register node type
LiteGraph.registerNodeType('dummy', DummyNode)
// Create graph with nodes
const graph = new LGraph()
const node = new DummyNode()
graph.add(node)
// Test serialization
const result = graph.serialize()
expect(result.nodes).toHaveLength(1)
expect(result.nodes[0].type).toBe('dummy')
})
})
```
## Working with Workflow JSON Files
Testing with ComfyUI workflow files:
```typescript
// Example from: tests-ui/tests/comfyWorkflow.test.ts
import { describe, expect, it } from 'vitest'
import { validateComfyWorkflow } from '@/schemas/comfyWorkflowSchema'
import { defaultGraph } from '@/scripts/defaultGraph'
describe('workflow validation', () => {
it('should validate default workflow', async () => {
const validWorkflow = JSON.parse(JSON.stringify(defaultGraph))
// Validate workflow
const result = await validateComfyWorkflow(validWorkflow)
expect(result).not.toBeNull()
})
it('should handle position format conversion', async () => {
const workflow = JSON.parse(JSON.stringify(defaultGraph))
// Legacy position format as object
workflow.nodes[0].pos = { '0': 100, '1': 200 }
// Should convert to array format
const result = await validateComfyWorkflow(workflow)
expect(result.nodes[0].pos).toEqual([100, 200])
})
})
```
## Mocking the API Object
Mocking the ComfyUI API object:
```typescript
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
import { describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
// Mock the api object
vi.mock('@/scripts/api', () => ({
api: {
subscribeLogs: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
it('should subscribe to logs API', () => {
// Call function that uses the API
startListening()
// Verify API was called correctly
expect(api.subscribeLogs).toHaveBeenCalledWith(true)
})
```
## Mocking Lodash Functions
Mocking lodash functions like debounce:
```typescript
// Mock debounce to execute immediately
import { debounce } from 'lodash-es'
vi.mock('lodash-es', () => ({
debounce: vi.fn((fn) => {
// Return function that calls the input function immediately
const mockDebounced = (...args: any[]) => fn(...args)
// Add cancel method that lodash debounced functions have
mockDebounced.cancel = vi.fn()
return mockDebounced
})
}))
describe('Function using debounce', () => {
it('calls debounced function immediately in tests', () => {
const mockFn = vi.fn()
const debouncedFn = debounce(mockFn, 1000)
debouncedFn()
// No need to wait - our mock makes it execute immediately
expect(mockFn).toHaveBeenCalled()
})
})
```
## Testing with Debounce and Throttle
When you need to test real debounce/throttle behavior:
```typescript
// Example from: tests-ui/tests/composables/useWorkflowAutoSave.test.ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('debounced function', () => {
beforeEach(() => {
vi.useFakeTimers() // Use fake timers to control time
})
afterEach(() => {
vi.useRealTimers()
})
it('should debounce function calls', () => {
const mockFn = vi.fn()
const debouncedFn = debounce(mockFn, 1000)
// Call multiple times
debouncedFn()
debouncedFn()
debouncedFn()
// Function not called yet (debounced)
expect(mockFn).not.toHaveBeenCalled()
// Advance time just before debounce period
vi.advanceTimersByTime(999)
expect(mockFn).not.toHaveBeenCalled()
// Advance to debounce completion
vi.advanceTimersByTime(1)
expect(mockFn).toHaveBeenCalledTimes(1)
})
})
```
## Mocking Node Definitions
Creating mock node definitions for testing:
```typescript
// Example from: tests-ui/tests/apiTypes.test.ts
import { describe, expect, it } from 'vitest'
import { type ComfyNodeDef, validateComfyNodeDef } from '@/schemas/nodeDefSchema'
// Create a complete mock node definition
const EXAMPLE_NODE_DEF: ComfyNodeDef = {
input: {
required: {
ckpt_name: [['model1.safetensors', 'model2.ckpt'], {}]
}
},
output: ['MODEL', 'CLIP', 'VAE'],
output_is_list: [false, false, false],
output_name: ['MODEL', 'CLIP', 'VAE'],
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
description: '',
python_module: 'nodes',
category: 'loaders',
output_node: false,
experimental: false,
deprecated: false
}
it('should validate node definition', () => {
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
})
```