mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-31 17:49:14 +00:00
Compare commits
8 Commits
docs/folde
...
rh-algolia
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52a93f620e | ||
|
|
6d87f2b2ff | ||
|
|
20911aa892 | ||
|
|
3a6018589e | ||
|
|
4c92a7142e | ||
|
|
293993e7de | ||
|
|
a7ee3fae05 | ||
|
|
22dc84324e |
27
README.md
27
README.md
@@ -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
|
||||
|
||||
|
||||
139
browser_tests/tests/chatHistory.spec.ts
Normal file
139
browser_tests/tests/chatHistory.spec.ts
Normal 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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? ''
|
||||
|
||||
122
src/components/topbar/CurrentUserButton.spec.ts
Normal file
122
src/components/topbar/CurrentUserButton.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
173
src/components/topbar/CurrentUserPopover.spec.ts
Normal file
173
src/components/topbar/CurrentUserPopover.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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(() => {
|
||||
|
||||
310
src/composables/README.md
Normal file
310
src/composables/README.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Composables
|
||||
|
||||
This directory contains Vue composables for the ComfyUI frontend application. Composables are reusable pieces of logic that encapsulate stateful functionality and can be shared across components.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Composable Architecture](#composable-architecture)
|
||||
- [Composable Categories](#composable-categories)
|
||||
- [Usage Guidelines](#usage-guidelines)
|
||||
- [VueUse Library](#vueuse-library)
|
||||
- [Development Guidelines](#development-guidelines)
|
||||
- [Common Patterns](#common-patterns)
|
||||
|
||||
## Overview
|
||||
|
||||
Vue composables are a core part of Vue 3's Composition API and provide a way to extract and reuse stateful logic between multiple components. In ComfyUI, composables are used to encapsulate behaviors like:
|
||||
|
||||
- State management
|
||||
- DOM interactions
|
||||
- Feature-specific functionality
|
||||
- UI behaviors
|
||||
- Data fetching
|
||||
|
||||
Composables enable a more modular and functional approach to building components, allowing for better code reuse and separation of concerns. They help keep your component code cleaner by extracting complex logic into separate, reusable functions.
|
||||
|
||||
As described in the [Vue.js documentation](https://vuejs.org/guide/reusability/composables.html), composables are:
|
||||
> Functions that leverage Vue's Composition API to encapsulate and reuse stateful logic.
|
||||
|
||||
## Composable Architecture
|
||||
|
||||
The composable architecture in ComfyUI follows these principles:
|
||||
|
||||
1. **Single Responsibility**: Each composable should focus on a specific concern
|
||||
2. **Composition**: Composables can use other composables
|
||||
3. **Reactivity**: Composables leverage Vue's reactivity system
|
||||
4. **Reusability**: Composables are designed to be used across multiple components
|
||||
|
||||
The following diagram shows how composables fit into the application architecture:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Vue Components │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Component A │ │ Component B │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │
|
||||
└────────────┼───────────────────┼────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────┴───────────────────┴────────────────────────┐
|
||||
│ Composables │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ useFeatureA │ │ useFeatureB │ │ useFeatureC │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
└─────────┼────────────────┼────────────────┼─────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┴────────────────┴────────────────┴─────────────┐
|
||||
│ Services & Stores │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Composable Categories
|
||||
|
||||
ComfyUI's composables are organized into several categories:
|
||||
|
||||
### Auth
|
||||
|
||||
Composables for authentication and user management:
|
||||
- `useCurrentUser` - Provides access to the current user information
|
||||
- `useFirebaseAuthActions` - Handles Firebase authentication operations
|
||||
|
||||
### Element
|
||||
|
||||
Composables for DOM and element interactions:
|
||||
- `useAbsolutePosition` - Handles element positioning
|
||||
- `useDomClipping` - Manages clipping of DOM elements
|
||||
- `useResponsiveCollapse` - Manages responsive collapsing of elements
|
||||
|
||||
### Node
|
||||
|
||||
Composables for node-specific functionality:
|
||||
- `useNodeBadge` - Handles node badge display and interaction
|
||||
- `useNodeImage` - Manages node image preview
|
||||
- `useNodeDragAndDrop` - Handles drag and drop for nodes
|
||||
- `useNodeChatHistory` - Manages chat history for nodes
|
||||
|
||||
### Settings
|
||||
|
||||
Composables for settings management:
|
||||
- `useSettingSearch` - Provides search functionality for settings
|
||||
- `useSettingUI` - Manages settings UI interactions
|
||||
|
||||
### Sidebar
|
||||
|
||||
Composables for sidebar functionality:
|
||||
- `useNodeLibrarySidebarTab` - Manages the node library sidebar tab
|
||||
- `useQueueSidebarTab` - Manages the queue sidebar tab
|
||||
- `useWorkflowsSidebarTab` - Manages the workflows sidebar tab
|
||||
|
||||
### Widgets
|
||||
|
||||
Composables for widget functionality:
|
||||
- `useBooleanWidget` - Manages boolean widget interactions
|
||||
- `useComboWidget` - Manages combo box widget interactions
|
||||
- `useFloatWidget` - Manages float input widget interactions
|
||||
- `useImagePreviewWidget` - Manages image preview widget
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
When using composables in components, follow these guidelines:
|
||||
|
||||
1. **Import and call** composables at the top level of the `setup` function
|
||||
2. **Destructure returned values** to use in your component
|
||||
3. **Respect reactivity** by not destructuring reactive objects
|
||||
4. **Handle cleanup** by using `onUnmounted` when necessary
|
||||
5. **Use VueUse** for common functionality instead of writing from scratch
|
||||
|
||||
Example usage:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'dragging': isDragging }"
|
||||
@mousedown="startDrag"
|
||||
@mouseup="endDrag"
|
||||
>
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="Node preview" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop';
|
||||
import { useNodeImage } from '@/composables/node/useNodeImage';
|
||||
|
||||
// Use composables at the top level
|
||||
const { isDragging, startDrag, endDrag } = useNodeDragAndDrop();
|
||||
const { imageUrl, loadImage } = useNodeImage();
|
||||
|
||||
// Use returned values in your component
|
||||
</script>
|
||||
```
|
||||
|
||||
## VueUse Library
|
||||
|
||||
ComfyUI leverages the [VueUse](https://vueuse.org/) library, which provides a collection of essential Vue Composition API utilities. Instead of implementing common functionality from scratch, prefer using VueUse composables for:
|
||||
|
||||
- DOM event handling (`useEventListener`, `useMouseInElement`)
|
||||
- Element measurements (`useElementBounding`, `useElementSize`)
|
||||
- Asynchronous operations (`useAsyncState`, `useFetch`)
|
||||
- Animation and timing (`useTransition`, `useTimeout`, `useInterval`)
|
||||
- Browser APIs (`useLocalStorage`, `useClipboard`)
|
||||
- Sensors (`useDeviceMotion`, `useDeviceOrientation`)
|
||||
- State management (`createGlobalState`, `useStorage`)
|
||||
- ...and [more](https://vueuse.org/functions.html)
|
||||
|
||||
Examples:
|
||||
|
||||
```js
|
||||
// Instead of manually adding/removing event listeners
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
useEventListener(window, 'resize', handleResize)
|
||||
|
||||
// Instead of manually tracking element measurements
|
||||
import { useElementBounding } from '@vueuse/core'
|
||||
|
||||
const { width, height, top, left } = useElementBounding(elementRef)
|
||||
|
||||
// Instead of manual async state management
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
|
||||
const { state, isReady, isLoading } = useAsyncState(
|
||||
fetch('https://api.example.com/data').then(r => r.json()),
|
||||
{ data: [] }
|
||||
)
|
||||
```
|
||||
|
||||
For a complete list of available functions, see the [VueUse documentation](https://vueuse.org/functions.html).
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
When creating or modifying composables, follow these best practices:
|
||||
|
||||
1. **Name with `use` prefix**: All composables should start with "use"
|
||||
2. **Return an object**: Composables should return an object with named properties/methods
|
||||
3. **Handle cleanup**: Use `onUnmounted` to clean up resources
|
||||
4. **Document parameters and return values**: Add JSDoc comments
|
||||
5. **Test composables**: Write unit tests for composable functionality
|
||||
6. **Use VueUse**: Leverage VueUse composables instead of reimplementing common functionality
|
||||
7. **Implement proper cleanup**: Cancel debounced functions, pending requests, and clear maps
|
||||
8. **Use watchDebounced/watchThrottled**: For performance-sensitive reactive operations
|
||||
|
||||
### Composable Template
|
||||
|
||||
Here's a template for creating a new composable:
|
||||
|
||||
```typescript
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
/**
|
||||
* Composable for [functionality description]
|
||||
* @param options Configuration options
|
||||
* @returns Object containing state and methods
|
||||
*/
|
||||
export function useExample(options = {}) {
|
||||
// State
|
||||
const state = ref({
|
||||
// Initial state
|
||||
});
|
||||
|
||||
// Computed values
|
||||
const derivedValue = computed(() => {
|
||||
// Compute from state
|
||||
return state.value.someProperty;
|
||||
});
|
||||
|
||||
// Methods
|
||||
function doSomething() {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
// Setup
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Cleanup
|
||||
});
|
||||
|
||||
// Return exposed state and methods
|
||||
return {
|
||||
state,
|
||||
derivedValue,
|
||||
doSomething
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
Composables in ComfyUI frequently use these patterns:
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
export function useState() {
|
||||
const count = ref(0);
|
||||
|
||||
function increment() {
|
||||
count.value++;
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
increment
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Event Handling with VueUse
|
||||
|
||||
```typescript
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
|
||||
export function useKeyPress(key) {
|
||||
const isPressed = ref(false);
|
||||
|
||||
useEventListener('keydown', (e) => {
|
||||
if (e.key === key) {
|
||||
isPressed.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener('keyup', (e) => {
|
||||
if (e.key === key) {
|
||||
isPressed.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { isPressed };
|
||||
}
|
||||
```
|
||||
|
||||
### Fetch & Load with VueUse
|
||||
|
||||
```typescript
|
||||
import { useAsyncState } from '@vueuse/core';
|
||||
|
||||
export function useFetchData(url) {
|
||||
const { state: data, isLoading, error, execute: refresh } = useAsyncState(
|
||||
async () => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error('Failed to fetch data');
|
||||
return response.json();
|
||||
},
|
||||
null,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return { data, isLoading, error, refresh };
|
||||
}
|
||||
```
|
||||
|
||||
For more information on Vue composables, refer to the [Vue.js Composition API documentation](https://vuejs.org/guide/reusability/composables.html) and the [VueUse documentation](https://vueuse.org/).
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
139
src/extensions/core/README.md
Normal file
139
src/extensions/core/README.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Core Extensions
|
||||
|
||||
This directory contains the core extensions that provide essential functionality to the ComfyUI frontend.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Extension Architecture](#extension-architecture)
|
||||
- [Core Extensions](#core-extensions)
|
||||
- [Extension Development](#extension-development)
|
||||
- [Extension Hooks](#extension-hooks)
|
||||
- [Further Reading](#further-reading)
|
||||
|
||||
## Overview
|
||||
|
||||
Extensions in ComfyUI are modular JavaScript modules that extend and enhance the functionality of the frontend. The extensions in this directory are considered "core" as they provide fundamental features that are built into ComfyUI by default.
|
||||
|
||||
## Extension Architecture
|
||||
|
||||
ComfyUI's extension system follows these key principles:
|
||||
|
||||
1. **Registration-based:** Extensions must register themselves with the application using `app.registerExtension()`
|
||||
2. **Hook-driven:** Extensions interact with the system through predefined hooks
|
||||
3. **Non-intrusive:** Extensions should avoid directly modifying core objects where possible
|
||||
|
||||
## Core Extensions List
|
||||
|
||||
The core extensions include:
|
||||
|
||||
| Extension | Description |
|
||||
|-----------|-------------|
|
||||
| clipspace.ts | Implements the Clipspace feature for temporary image storage |
|
||||
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities |
|
||||
| groupNode.ts | Implements the group node functionality to organize workflows |
|
||||
| load3d.ts | Supports 3D model loading and visualization |
|
||||
| maskeditor.ts | Implements the mask editor for image masking operations |
|
||||
| noteNode.ts | Adds note nodes for documentation within workflows |
|
||||
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections |
|
||||
| uploadImage.ts | Handles image upload functionality |
|
||||
| webcamCapture.ts | Provides webcam capture capabilities |
|
||||
| widgetInputs.ts | Implements various widget input types |
|
||||
|
||||
## Extension Development
|
||||
|
||||
When developing or modifying extensions, follow these best practices:
|
||||
|
||||
1. **Use provided hooks** rather than directly modifying core application objects
|
||||
2. **Maintain compatibility** with other extensions
|
||||
3. **Follow naming conventions** for both extension names and settings
|
||||
4. **Properly document** extension hooks and functionality
|
||||
5. **Test with other extensions** to ensure no conflicts
|
||||
|
||||
### Extension Registration
|
||||
|
||||
Extensions are registered using the `app.registerExtension()` method:
|
||||
|
||||
```javascript
|
||||
app.registerExtension({
|
||||
name: "MyExtension",
|
||||
|
||||
// Hook implementations
|
||||
async init() {
|
||||
// Implementation
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
// Other hooks as needed
|
||||
});
|
||||
```
|
||||
|
||||
## Extension Hooks
|
||||
|
||||
ComfyUI extensions can implement various hooks that are called at specific points in the application lifecycle:
|
||||
|
||||
### Hook Execution Sequence
|
||||
|
||||
#### Web Page Load
|
||||
|
||||
```
|
||||
init
|
||||
addCustomNodeDefs
|
||||
getCustomWidgets
|
||||
beforeRegisterNodeDef [repeated multiple times]
|
||||
registerCustomNodes
|
||||
beforeConfigureGraph
|
||||
nodeCreated
|
||||
loadedGraphNode
|
||||
afterConfigureGraph
|
||||
setup
|
||||
```
|
||||
|
||||
#### Loading Workflow
|
||||
|
||||
```
|
||||
beforeConfigureGraph
|
||||
beforeRegisterNodeDef [zero, one, or multiple times]
|
||||
nodeCreated [repeated multiple times]
|
||||
loadedGraphNode [repeated multiple times]
|
||||
afterConfigureGraph
|
||||
```
|
||||
|
||||
#### Adding New Node
|
||||
|
||||
```
|
||||
nodeCreated
|
||||
```
|
||||
|
||||
### Key Hooks
|
||||
|
||||
| Hook | Description |
|
||||
|------|-------------|
|
||||
| `init` | Called after canvas creation but before nodes are added |
|
||||
| `setup` | Called after the application is fully set up and running |
|
||||
| `addCustomNodeDefs` | Called before nodes are registered with the graph |
|
||||
| `getCustomWidgets` | Allows extensions to add custom widgets |
|
||||
| `beforeRegisterNodeDef` | Allows extensions to modify nodes before registration |
|
||||
| `registerCustomNodes` | Allows extensions to register additional nodes |
|
||||
| `loadedGraphNode` | Called when a node is reloaded onto the graph |
|
||||
| `nodeCreated` | Called after a node's constructor |
|
||||
| `beforeConfigureGraph` | Called before a graph is configured |
|
||||
| `afterConfigureGraph` | Called after a graph is configured |
|
||||
| `getSelectionToolboxCommands` | Allows extensions to add commands to the selection toolbox |
|
||||
|
||||
For the complete list of available hooks and detailed descriptions, see the [ComfyExtension interface in comfy.ts](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/types/comfy.ts).
|
||||
|
||||
## Further Reading
|
||||
|
||||
For more detailed information about ComfyUI's extension system, refer to the official documentation:
|
||||
|
||||
- [JavaScript Extension Overview](https://docs.comfy.org/custom-nodes/js/javascript_overview)
|
||||
- [JavaScript Hooks](https://docs.comfy.org/custom-nodes/js/javascript_hooks)
|
||||
- [JavaScript Objects and Hijacking](https://docs.comfy.org/custom-nodes/js/javascript_objects_and_hijacking)
|
||||
- [JavaScript Settings](https://docs.comfy.org/custom-nodes/js/javascript_settings)
|
||||
- [JavaScript Examples](https://docs.comfy.org/custom-nodes/js/javascript_examples)
|
||||
|
||||
Also, check the main [README.md](https://github.com/Comfy-Org/ComfyUI_frontend#developer-apis) section on Developer APIs for the latest information on extension APIs and features.
|
||||
257
src/services/README.md
Normal file
257
src/services/README.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Services
|
||||
|
||||
This directory contains the service layer for the ComfyUI frontend application. Services encapsulate application logic and functionality into organized, reusable modules.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Service Architecture](#service-architecture)
|
||||
- [Core Services](#core-services)
|
||||
- [Service Development Guidelines](#service-development-guidelines)
|
||||
- [Common Design Patterns](#common-design-patterns)
|
||||
|
||||
## Overview
|
||||
|
||||
Services in ComfyUI provide organized modules that implement the application's functionality and logic. They handle operations such as API communication, workflow management, user settings, and other essential features.
|
||||
|
||||
The term "business logic" in this context refers to the code that implements the core functionality and behavior of the application - the rules, processes, and operations that make ComfyUI work as expected, separate from the UI display code.
|
||||
|
||||
Services help organize related functionality into cohesive units, making the codebase more maintainable and testable. By centralizing related operations in services, the application achieves better separation of concerns, with UI components focusing on presentation and services handling functional operations.
|
||||
|
||||
## Service Architecture
|
||||
|
||||
The service layer in ComfyUI follows these architectural principles:
|
||||
|
||||
1. **Domain-driven**: Each service focuses on a specific domain of the application
|
||||
2. **Stateless when possible**: Services generally avoid maintaining internal state
|
||||
3. **Reusable**: Services can be used across multiple components
|
||||
4. **Testable**: Services are designed for easy unit testing
|
||||
5. **Isolated**: Services have clear boundaries and dependencies
|
||||
|
||||
While services can interact with both UI components and stores (centralized state), they primarily focus on implementing functionality rather than managing state. The following diagram illustrates how services fit into the application architecture:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ UI Components │
|
||||
└────────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Composables │
|
||||
└────────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Services │
|
||||
│ │
|
||||
│ (Application Functionality) │
|
||||
└────────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
▼ ▼
|
||||
┌───────────────────────────┐ ┌─────────────────────────┐
|
||||
│ Stores │ │ External APIs │
|
||||
│ (Centralized State) │ │ │
|
||||
└───────────────────────────┘ └─────────────────────────┘
|
||||
```
|
||||
|
||||
## Core Services
|
||||
|
||||
The core services include:
|
||||
|
||||
| Service | Description |
|
||||
|---------|-------------|
|
||||
| algoliaSearchService.ts | Implements search functionality using Algolia |
|
||||
| autoQueueService.ts | Manages automatic queue execution |
|
||||
| colorPaletteService.ts | Handles color palette management and customization |
|
||||
| comfyManagerService.ts | Manages ComfyUI application packages and updates |
|
||||
| comfyRegistryService.ts | Handles registration and discovery of ComfyUI extensions |
|
||||
| dialogService.ts | Provides dialog and modal management |
|
||||
| extensionService.ts | Manages extension registration and lifecycle |
|
||||
| keybindingService.ts | Handles keyboard shortcuts and keybindings |
|
||||
| litegraphService.ts | Provides utilities for working with the LiteGraph library |
|
||||
| load3dService.ts | Manages 3D model loading and visualization |
|
||||
| nodeSearchService.ts | Implements node search functionality |
|
||||
| workflowService.ts | Handles workflow operations (save, load, execute) |
|
||||
|
||||
## Service Development Guidelines
|
||||
|
||||
In ComfyUI, services can be implemented using two approaches:
|
||||
|
||||
### 1. Class-based Services
|
||||
|
||||
For complex services with state management and multiple methods, class-based services are used:
|
||||
|
||||
```typescript
|
||||
export class NodeSearchService {
|
||||
// Service state
|
||||
private readonly nodeFuseSearch: FuseSearch<ComfyNodeDefImpl>
|
||||
private readonly filters: Record<string, FuseFilter<ComfyNodeDefImpl, string>>
|
||||
|
||||
constructor(data: ComfyNodeDefImpl[]) {
|
||||
// Initialize state
|
||||
this.nodeFuseSearch = new FuseSearch(data, { /* options */ })
|
||||
|
||||
// Setup filters
|
||||
this.filters = {
|
||||
inputType: new FuseFilter<ComfyNodeDefImpl, string>(/* options */),
|
||||
category: new FuseFilter<ComfyNodeDefImpl, string>(/* options */)
|
||||
}
|
||||
}
|
||||
|
||||
public searchNode(query: string, filters: FuseFilterWithValue[] = []): ComfyNodeDefImpl[] {
|
||||
// Implementation
|
||||
return results
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Composable-style Services
|
||||
|
||||
For simpler services or those that need to integrate with Vue's reactivity system, we prefer using composable-style services:
|
||||
|
||||
```typescript
|
||||
export function useNodeSearchService(initialData: ComfyNodeDefImpl[]) {
|
||||
// State (reactive if needed)
|
||||
const data = ref(initialData)
|
||||
|
||||
// Search functionality
|
||||
function searchNodes(query: string) {
|
||||
// Implementation
|
||||
return results
|
||||
}
|
||||
|
||||
// Additional methods
|
||||
function refreshData(newData: ComfyNodeDefImpl[]) {
|
||||
data.value = newData
|
||||
}
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
searchNodes,
|
||||
refreshData
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When deciding between these approaches, consider:
|
||||
|
||||
1. **Stateful vs. Stateless**: For stateful services, classes often provide clearer encapsulation
|
||||
2. **Reactivity needs**: If the service needs to be reactive, composable-style services integrate better with Vue's reactivity system
|
||||
3. **Complexity**: For complex services with many methods and internal state, classes can provide better organization
|
||||
4. **Testing**: Both approaches can be tested effectively, but composables may be simpler to test with Vue Test Utils
|
||||
|
||||
### Service Template
|
||||
|
||||
Here's a template for creating a new composable-style service:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Service for managing [domain/functionality]
|
||||
*/
|
||||
export function useExampleService() {
|
||||
// Private state/functionality
|
||||
const cache = new Map()
|
||||
|
||||
/**
|
||||
* Description of what this method does
|
||||
* @param param1 Description of parameter
|
||||
* @returns Description of return value
|
||||
*/
|
||||
async function performOperation(param1: string) {
|
||||
try {
|
||||
// Implementation
|
||||
return result
|
||||
} catch (error) {
|
||||
// Error handling
|
||||
console.error(`Operation failed: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Return public API
|
||||
return {
|
||||
performOperation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Design Patterns
|
||||
|
||||
Services in ComfyUI frequently use the following design patterns:
|
||||
|
||||
### Caching and Request Deduplication
|
||||
|
||||
```typescript
|
||||
export function useCachedService() {
|
||||
const cache = new Map()
|
||||
const pendingRequests = new Map()
|
||||
|
||||
async function fetchData(key: string) {
|
||||
// Check cache first
|
||||
if (cache.has(key)) return cache.get(key)
|
||||
|
||||
// Check if request is already in progress
|
||||
if (pendingRequests.has(key)) {
|
||||
return pendingRequests.get(key)
|
||||
}
|
||||
|
||||
// Perform new request
|
||||
const requestPromise = fetch(`/api/${key}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
cache.set(key, data)
|
||||
pendingRequests.delete(key)
|
||||
return data
|
||||
})
|
||||
|
||||
pendingRequests.set(key, requestPromise)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
return { fetchData }
|
||||
}
|
||||
```
|
||||
|
||||
### Factory Pattern
|
||||
|
||||
```typescript
|
||||
export function useNodeFactory() {
|
||||
function createNode(type: string, config: Record<string, any>) {
|
||||
// Create node based on type and configuration
|
||||
switch (type) {
|
||||
case 'basic':
|
||||
return { /* basic node implementation */ }
|
||||
case 'complex':
|
||||
return { /* complex node implementation */ }
|
||||
default:
|
||||
throw new Error(`Unknown node type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { createNode }
|
||||
}
|
||||
```
|
||||
|
||||
### Facade Pattern
|
||||
|
||||
```typescript
|
||||
export function useWorkflowService(
|
||||
apiService,
|
||||
graphService,
|
||||
storageService
|
||||
) {
|
||||
// Provides a simple interface to complex subsystems
|
||||
async function saveWorkflow(name: string) {
|
||||
const graphData = graphService.serializeGraph()
|
||||
const storagePath = await storageService.getPath(name)
|
||||
return apiService.saveData(storagePath, graphData)
|
||||
}
|
||||
|
||||
return { saveWorkflow }
|
||||
}
|
||||
```
|
||||
|
||||
For more detailed information about the service layer pattern and its applications, refer to:
|
||||
- [Service Layer Pattern](https://en.wikipedia.org/wiki/Service_layer_pattern)
|
||||
- [Service-Orientation](https://en.wikipedia.org/wiki/Service-orientation)
|
||||
@@ -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
|
||||
})
|
||||
|
||||
356
src/stores/README.md
Normal file
356
src/stores/README.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# Stores
|
||||
|
||||
This directory contains Pinia stores for the ComfyUI frontend application. Stores provide centralized state management for the application.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Store Architecture](#store-architecture)
|
||||
- [Core Stores](#core-stores)
|
||||
- [Store Development Guidelines](#store-development-guidelines)
|
||||
- [Common Patterns](#common-patterns)
|
||||
- [Testing Stores](#testing-stores)
|
||||
|
||||
## Overview
|
||||
|
||||
Stores in ComfyUI use [Pinia](https://pinia.vuejs.org/), Vue's official state management library. Each store is responsible for managing a specific domain of the application state, such as user data, workflow information, graph state, and UI configuration.
|
||||
|
||||
Stores provide a way to maintain global application state that can be accessed from any component, regardless of where those components are in the component hierarchy. This solves the problem of "prop drilling" (passing data down through multiple levels of components) and allows components that aren't directly related to share and modify the same state.
|
||||
|
||||
For example, without global state:
|
||||
```
|
||||
App
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ │
|
||||
HeaderBar Canvas
|
||||
│ │
|
||||
│ │
|
||||
UserMenu NodeProperties
|
||||
```
|
||||
|
||||
In this structure, if the `UserMenu` component needs to update something that affects `NodeProperties`, the data would need to be passed up to `App` and then down again, through all intermediate components.
|
||||
|
||||
With Pinia stores, components can directly access and update the shared state:
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ │
|
||||
│ Pinia Stores │
|
||||
│ │
|
||||
└───────┬─────────┘
|
||||
│
|
||||
│ Accessed by
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ │
|
||||
│ Components │
|
||||
│ │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
## Store Architecture
|
||||
|
||||
The store architecture in ComfyUI follows these principles:
|
||||
|
||||
1. **Domain-driven**: Each store focuses on a specific domain of the application
|
||||
2. **Single source of truth**: Stores serve as the definitive source for specific data
|
||||
3. **Composition**: Stores can interact with other stores when needed
|
||||
4. **Actions for logic**: Business logic is encapsulated in store actions
|
||||
5. **Getters for derived state**: Computed values are exposed via getters
|
||||
|
||||
The following diagram illustrates the store architecture and data flow:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Vue Components │
|
||||
│ │
|
||||
│ ┌───────────────┐ ┌───────────────┐ │
|
||||
│ │ Component A │ │ Component B │ │
|
||||
│ └───────┬───────┘ └───────┬───────┘ │
|
||||
│ │ │ │
|
||||
└───────────┼────────────────────────────┼────────────────┘
|
||||
│ │
|
||||
│ ┌───────────────┐ │
|
||||
└────►│ Composables │◄─────┘
|
||||
└───────┬───────┘
|
||||
│
|
||||
┌─────────────────────────┼─────────────────────────────┐
|
||||
│ Pinia Stores │ │
|
||||
│ │ │
|
||||
│ ┌───────────────────▼───────────────────────┐ │
|
||||
│ │ Actions │ │
|
||||
│ └───────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────▼───────────────────────┐ │
|
||||
│ │ State │ │
|
||||
│ └───────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌───────────────────▼───────────────────────┐ │
|
||||
│ │ Getters │ │
|
||||
│ └───────────────────┬───────────────────────┘ │
|
||||
│ │ │
|
||||
└─────────────────────────┼─────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ External Services │
|
||||
│ (API, localStorage, WebSocket, etc.) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Core Stores
|
||||
|
||||
The core stores include:
|
||||
|
||||
| Store | Description |
|
||||
|-------|-------------|
|
||||
| aboutPanelStore.ts | Manages the About panel state and badges |
|
||||
| apiKeyAuthStore.ts | Handles API key authentication |
|
||||
| comfyManagerStore.ts | Manages ComfyUI application state |
|
||||
| comfyRegistryStore.ts | Handles extensions registry |
|
||||
| commandStore.ts | Manages commands and command execution |
|
||||
| dialogStore.ts | Controls dialog/modal display and state |
|
||||
| domWidgetStore.ts | Manages DOM widget state |
|
||||
| executionStore.ts | Tracks workflow execution state |
|
||||
| extensionStore.ts | Manages extension registration and state |
|
||||
| firebaseAuthStore.ts | Handles Firebase authentication |
|
||||
| graphStore.ts | Manages the graph canvas state |
|
||||
| imagePreviewStore.ts | Controls image preview functionality |
|
||||
| keybindingStore.ts | Manages keyboard shortcuts |
|
||||
| menuItemStore.ts | Handles menu items and their state |
|
||||
| modelStore.ts | Manages AI models information |
|
||||
| nodeDefStore.ts | Manages node definitions |
|
||||
| queueStore.ts | Handles the execution queue |
|
||||
| settingStore.ts | Manages application settings |
|
||||
| userStore.ts | Manages user data and preferences |
|
||||
| workflowStore.ts | Handles workflow data and operations |
|
||||
| workspace/* | Stores related to the workspace UI |
|
||||
|
||||
## Store Development Guidelines
|
||||
|
||||
When developing or modifying stores, follow these best practices:
|
||||
|
||||
1. **Define clear purpose**: Each store should have a specific responsibility
|
||||
2. **Use actions for async operations**: Encapsulate asynchronous logic in actions
|
||||
3. **Keep stores focused**: Each store should manage related state
|
||||
4. **Document public API**: Add comments for state properties, actions, and getters
|
||||
5. **Use getters for derived state**: Compute derived values using getters
|
||||
6. **Test store functionality**: Write unit tests for stores
|
||||
|
||||
### Store Template
|
||||
|
||||
Here's a template for creating a new Pinia store, following the setup style used in ComfyUI:
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export const useExampleStore = defineStore('example', () => {
|
||||
// State
|
||||
const items = ref([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Getters
|
||||
const itemCount = computed(() => items.value.length)
|
||||
const hasError = computed(() => error.value !== null)
|
||||
|
||||
// Actions
|
||||
function addItem(item) {
|
||||
items.value.push(item)
|
||||
}
|
||||
|
||||
async function fetchItems() {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/items')
|
||||
const data = await response.json()
|
||||
items.value = data
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Expose state, getters, and actions
|
||||
return {
|
||||
// State
|
||||
items,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Getters
|
||||
itemCount,
|
||||
hasError,
|
||||
|
||||
// Actions
|
||||
addItem,
|
||||
fetchItems
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
Stores in ComfyUI frequently use these patterns:
|
||||
|
||||
### API Integration
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
export const useDataStore = defineStore('data', () => {
|
||||
const data = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await api.getData()
|
||||
data.value = result
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
fetchData
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Store Composition
|
||||
|
||||
```typescript
|
||||
import { defineStore, storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useOtherStore } from './otherStore'
|
||||
|
||||
export const useComposedStore = defineStore('composed', () => {
|
||||
const otherStore = useOtherStore()
|
||||
const { someData } = storeToRefs(otherStore)
|
||||
|
||||
// Local state
|
||||
const localState = ref(0)
|
||||
|
||||
// Computed value based on other store
|
||||
const derivedValue = computed(() => {
|
||||
return computeFromOtherData(someData.value, localState.value)
|
||||
})
|
||||
|
||||
// Action that uses another store
|
||||
async function complexAction() {
|
||||
await otherStore.someAction()
|
||||
localState.value += 1
|
||||
}
|
||||
|
||||
return {
|
||||
localState,
|
||||
derivedValue,
|
||||
complexAction
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Persistent State
|
||||
|
||||
```typescript
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export const usePreferencesStore = defineStore('preferences', () => {
|
||||
// Load from localStorage if available
|
||||
const theme = ref(localStorage.getItem('theme') || 'light')
|
||||
const fontSize = ref(parseInt(localStorage.getItem('fontSize') || '14'))
|
||||
|
||||
// Save to localStorage when changed
|
||||
watch(theme, (newTheme) => {
|
||||
localStorage.setItem('theme', newTheme)
|
||||
})
|
||||
|
||||
watch(fontSize, (newSize) => {
|
||||
localStorage.setItem('fontSize', newSize.toString())
|
||||
})
|
||||
|
||||
function setTheme(newTheme) {
|
||||
theme.value = newTheme
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
fontSize,
|
||||
setTheme
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Testing Stores
|
||||
|
||||
Stores should be tested to ensure they behave as expected. Here's an example of how to test a store:
|
||||
|
||||
```typescript
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExampleStore } from '@/stores/exampleStore'
|
||||
|
||||
// Mock API dependencies
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useExampleStore', () => {
|
||||
let store: ReturnType<typeof useExampleStore>
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh pinia instance and make it active
|
||||
setActivePinia(createPinia())
|
||||
store = useExampleStore()
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should initialize with default state', () => {
|
||||
expect(store.items).toEqual([])
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should add an item', () => {
|
||||
store.addItem('test')
|
||||
expect(store.items).toEqual(['test'])
|
||||
expect(store.itemCount).toBe(1)
|
||||
})
|
||||
|
||||
it('should fetch items', async () => {
|
||||
// Setup mock response
|
||||
vi.mocked(api.getData).mockResolvedValue(['item1', 'item2'])
|
||||
|
||||
// Call the action
|
||||
await store.fetchItems()
|
||||
|
||||
// Verify state changes
|
||||
expect(store.isLoading).toBe(false)
|
||||
expect(store.items).toEqual(['item1', 'item2'])
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
For more information on Pinia, refer to the [Pinia documentation](https://pinia.vuejs.org/introduction.html).
|
||||
45
tests-ui/README.md
Normal file
45
tests-ui/README.md
Normal 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.
|
||||
370
tests-ui/component-testing.md
Normal file
370
tests-ui/component-testing.md
Normal 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
280
tests-ui/store-testing.md
Normal 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)
|
||||
})
|
||||
})
|
||||
```
|
||||
95
tests-ui/tests/components/ChatHistoryWidget.spec.ts
Normal file
95
tests-ui/tests/components/ChatHistoryWidget.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
63
tests-ui/tests/composables/useNodeChatHistory.test.ts
Normal file
63
tests-ui/tests/composables/useNodeChatHistory.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
82
tests-ui/tests/store/executionStore.test.ts
Normal file
82
tests-ui/tests/store/executionStore.test.ts
Normal 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
251
tests-ui/unit-testing.md
Normal 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()
|
||||
})
|
||||
```
|
||||
Reference in New Issue
Block a user