diff --git a/src/components/common/DotSpinner.vue b/src/components/common/DotSpinner.vue new file mode 100644 index 0000000000..548737bdf5 --- /dev/null +++ b/src/components/common/DotSpinner.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/src/components/dialog/content/manager/button/PackInstallButton.vue b/src/components/dialog/content/manager/button/PackInstallButton.vue index 7bba01ef0a..2f9751d0fd 100644 --- a/src/components/dialog/content/manager/button/PackInstallButton.vue +++ b/src/components/dialog/content/manager/button/PackInstallButton.vue @@ -10,7 +10,6 @@ :loading="isInstalling" :loading-message="$t('g.installing')" @action="installAllPacks" - @click="onClick" /> @@ -37,10 +36,6 @@ const { nodePacks, variant, label } = defineProps<{ const isInstalling = inject(IsInstallingKey, ref(false)) -const onClick = (): void => { - isInstalling.value = true -} - const managerStore = useComfyManagerStore() const createPayload = (installItem: NodePack) => { @@ -65,8 +60,6 @@ const installPack = (item: NodePack) => const installAllPacks = async () => { if (!nodePacks?.length) return - isInstalling.value = true - const uninstalledPacks = nodePacks.filter( (pack) => !managerStore.isPackInstalled(pack.id) ) diff --git a/src/components/dialog/content/manager/packCard/PackCard.vue b/src/components/dialog/content/manager/packCard/PackCard.vue index 08caeb29cb..ee2cef92de 100644 --- a/src/components/dialog/content/manager/packCard/PackCard.vue +++ b/src/components/dialog/content/manager/packCard/PackCard.vue @@ -84,10 +84,9 @@ diff --git a/src/locales/en/main.json b/src/locales/en/main.json index f036d9dbd6..b3871790e1 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -161,6 +161,12 @@ "inWorkflow": "In Workflow", "infoPanelEmpty": "Click an item to see the info", "restartToApplyChanges": "To apply changes, please restart ComfyUI", + "clickToFinishSetup": "Click", + "toFinishSetup": "to finish setup", + "applyChanges": "Apply Changes", + "restartingBackend": "Restarting backend to apply changes...", + "extensionsSuccessfullyInstalled": "Extension(s) successfully installed and are ready to use!", + "installingDependencies": "Installing dependencies...", "loadingVersions": "Loading versions...", "selectVersion": "Select Version", "downloads": "Downloads", diff --git a/src/stores/comfyManagerStore.ts b/src/stores/comfyManagerStore.ts index 0568711f77..b9840f4212 100644 --- a/src/stores/comfyManagerStore.ts +++ b/src/stores/comfyManagerStore.ts @@ -29,6 +29,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { const enabledPacksIds = ref>(new Set()) const disabledPacksIds = ref>(new Set()) const installedPacksIds = ref>(new Set()) + const installingPacksIds = ref>(new Set()) const isStale = ref(true) const taskLogs = ref([]) @@ -49,6 +50,9 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { isInstalledPackId(packName) && enabledPacksIds.value.has(packName) + const isInstallingPackId = (packName: string | undefined): boolean => + !!packName && installingPacksIds.value.has(packName) + const packsToIdSet = (packs: ManagerPackInstalled[]) => packs.reduce((acc, pack) => { const id = pack.cnr_id || pack.aux_id @@ -117,7 +121,11 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { whenever(isStale, refreshInstalledList, { immediate: true }) whenever(uncompletedCount, () => showManagerProgressDialog()) - const withLogs = (task: () => Promise, taskName: string) => { + const withLogs = ( + task: () => Promise, + taskName: string, + packId?: string + ) => { const { startListening, stopListening, logs } = useServerLogs() const loggedTask = async () => { @@ -128,6 +136,9 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { const onComplete = async () => { await stopListening() + if (packId) { + installingPacksIds.value.delete(packId) + } setStale() } @@ -152,8 +163,11 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { } } + installingPacksIds.value.add(params.id) const task = () => managerService.installPack(params, signal) - enqueueTask(withLogs(task, `${actionDescription} ${params.id}`)) + enqueueTask( + withLogs(task, `${actionDescription} ${params.id}`, params.id) + ) }, { maxSize: 1 } ) @@ -162,14 +176,16 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { installPack.clear() installPack.cancel() const task = () => managerService.uninstallPack(params, signal) - enqueueTask(withLogs(task, t('manager.uninstalling', { id: params.id }))) + enqueueTask( + withLogs(task, t('manager.uninstalling', { id: params.id }), params.id) + ) } const updatePack = useCachedRequest( async (params: ManagerPackInfo, signal?: AbortSignal) => { updateAllPacks.cancel() const task = () => managerService.updatePack(params, signal) - enqueueTask(withLogs(task, t('g.updating', { id: params.id }))) + enqueueTask(withLogs(task, t('g.updating', { id: params.id }), params.id)) }, { maxSize: 1 } ) @@ -184,7 +200,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { const disablePack = (params: ManagerPackInfo, signal?: AbortSignal) => { const task = () => managerService.disablePack(params, signal) - enqueueTask(withLogs(task, t('g.disabling', { id: params.id }))) + enqueueTask(withLogs(task, t('g.disabling', { id: params.id }), params.id)) } const getInstalledPackVersion = (packId: string) => { @@ -212,6 +228,7 @@ export const useComfyManagerStore = defineStore('comfyManager', () => { installedPacksIds, isPackInstalled: isInstalledPackId, isPackEnabled: isEnabledPackId, + isPackInstalling: isInstallingPackId, getInstalledPackVersion, refreshInstalledList, diff --git a/tests-ui/tests/components/dialog/footer/ManagerProgressFooter.test.ts b/tests-ui/tests/components/dialog/footer/ManagerProgressFooter.test.ts new file mode 100644 index 0000000000..c92d4c89ad --- /dev/null +++ b/tests-ui/tests/components/dialog/footer/ManagerProgressFooter.test.ts @@ -0,0 +1,440 @@ +import { mount } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { createI18n } from 'vue-i18n' + +import ManagerProgressFooter from '@/components/dialog/footer/ManagerProgressFooter.vue' +import { useComfyManagerService } from '@/services/comfyManagerService' +import { + useComfyManagerStore, + useManagerProgressDialogStore +} from '@/stores/comfyManagerStore' +import { useCommandStore } from '@/stores/commandStore' +import { useDialogStore } from '@/stores/dialogStore' +import { useSettingStore } from '@/stores/settingStore' +import { TaskLog } from '@/types/comfyManagerTypes' + +// Mock modules +vi.mock('@/stores/comfyManagerStore') +vi.mock('@/stores/dialogStore') +vi.mock('@/stores/settingStore') +vi.mock('@/stores/commandStore') +vi.mock('@/services/comfyManagerService') + +// Mock useEventListener to capture the event handler +let reconnectHandler: (() => void) | null = null +vi.mock('@vueuse/core', async () => { + const actual = await vi.importActual('@vueuse/core') + return { + ...actual, + useEventListener: vi.fn( + (_target: any, event: string, handler: any, _options: any) => { + if (event === 'reconnected') { + reconnectHandler = handler + } + } + ) + } +}) +vi.mock('@/services/workflowService', () => ({ + useWorkflowService: vi.fn(() => ({ + reloadCurrentWorkflow: vi.fn().mockResolvedValue(undefined) + })) +})) +vi.mock('@/stores/workspace/colorPaletteStore', () => ({ + useColorPaletteStore: vi.fn(() => ({ + completedActivePalette: { + light_theme: false + } + })) +})) + +// Helper function to mount component with required setup +const mountComponent = (options: { captureError?: boolean } = {}) => { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { + en: {} + } + }) + + const config: any = { + global: { + plugins: [PrimeVue, i18n], + mocks: { + $t: (key: string) => key // Mock i18n translation + } + } + } + + // Add error handler for tests that expect errors + if (options.captureError) { + config.global.config = { + errorHandler: () => { + // Suppress error in test + } + } + } + + return mount(ManagerProgressFooter, config) +} + +describe('ManagerProgressFooter', () => { + const mockTaskLogs: TaskLog[] = [] + + const mockComfyManagerStore = { + uncompletedCount: 0, + taskLogs: mockTaskLogs, + allTasksDone: true, + clearLogs: vi.fn(), + setStale: vi.fn(), + // Add other required properties + isLoading: { value: false }, + error: { value: null }, + statusMessage: { value: 'DONE' }, + installedPacks: {}, + installedPacksIds: new Set(), + isPackInstalled: vi.fn(), + isPackEnabled: vi.fn(), + getInstalledPackVersion: vi.fn(), + refreshInstalledList: vi.fn(), + installPack: vi.fn(), + uninstallPack: vi.fn(), + updatePack: vi.fn(), + updateAllPacks: vi.fn(), + disablePack: vi.fn(), + enablePack: vi.fn() + } + + const mockDialogStore = { + closeDialog: vi.fn(), + // Add other required properties + dialogStack: { value: [] }, + showDialog: vi.fn(), + $id: 'dialog', + $state: {} as any, + $patch: vi.fn(), + $reset: vi.fn(), + $subscribe: vi.fn(), + $dispose: vi.fn(), + $onAction: vi.fn() + } + + const mockSettingStore = { + get: vi.fn().mockReturnValue(false), + set: vi.fn(), + // Add other required properties + settingValues: { value: {} }, + settingsById: { value: {} }, + exists: vi.fn(), + getDefaultValue: vi.fn(), + loadSettingValues: vi.fn(), + updateValue: vi.fn(), + $id: 'setting', + $state: {} as any, + $patch: vi.fn(), + $reset: vi.fn(), + $subscribe: vi.fn(), + $dispose: vi.fn(), + $onAction: vi.fn() + } + + const mockProgressDialogStore = { + isExpanded: false, + toggle: vi.fn(), + collapse: vi.fn(), + expand: vi.fn() + } + + const mockCommandStore = { + execute: vi.fn().mockResolvedValue(undefined) + } + + const mockComfyManagerService = { + rebootComfyUI: vi.fn().mockResolvedValue(null) + } + + beforeEach(() => { + vi.clearAllMocks() + // Reset task logs + mockTaskLogs.length = 0 + mockComfyManagerStore.taskLogs = mockTaskLogs + // Reset event handler + reconnectHandler = null + + vi.mocked(useComfyManagerStore).mockReturnValue( + mockComfyManagerStore as any + ) + vi.mocked(useDialogStore).mockReturnValue(mockDialogStore as any) + vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any) + vi.mocked(useManagerProgressDialogStore).mockReturnValue( + mockProgressDialogStore as any + ) + vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any) + vi.mocked(useComfyManagerService).mockReturnValue( + mockComfyManagerService as any + ) + }) + + describe('State 1: Queue Running', () => { + it('should display loading spinner and progress counter when queue is running', async () => { + // Setup queue running state + mockComfyManagerStore.uncompletedCount = 3 + mockTaskLogs.push( + { taskName: 'Installing pack1', logs: [] }, + { taskName: 'Installing pack2', logs: [] }, + { taskName: 'Installing pack3', logs: [] } + ) + + const wrapper = mountComponent() + + // Check loading spinner exists (DotSpinner component) + expect(wrapper.find('.inline-flex').exists()).toBe(true) + + // Check current task name is displayed + expect(wrapper.text()).toContain('Installing pack3') + + // Check progress counter (completed: 2 of 3) + expect(wrapper.text()).toMatch(/2.*3/) + + // Check expand/collapse button exists + const expandButton = wrapper.find('[aria-label="Expand"]') + expect(expandButton.exists()).toBe(true) + + // Check Apply Changes button is NOT shown + expect(wrapper.text()).not.toContain('manager.applyChanges') + }) + + it('should toggle expansion when expand button is clicked', async () => { + mockComfyManagerStore.uncompletedCount = 1 + mockTaskLogs.push({ taskName: 'Installing', logs: [] }) + + const wrapper = mountComponent() + + const expandButton = wrapper.find('[aria-label="Expand"]') + await expandButton.trigger('click') + + expect(mockProgressDialogStore.toggle).toHaveBeenCalled() + }) + }) + + describe('State 2: Tasks Completed (Waiting for Restart)', () => { + it('should display check mark and Apply Changes button when all tasks are done', async () => { + // Setup tasks completed state + mockComfyManagerStore.uncompletedCount = 0 + mockTaskLogs.push( + { taskName: 'Installed pack1', logs: [] }, + { taskName: 'Installed pack2', logs: [] } + ) + mockComfyManagerStore.allTasksDone = true + + const wrapper = mountComponent() + + // Check check mark emoji + expect(wrapper.text()).toContain('✅') + + // Check restart message (split into 3 parts) + expect(wrapper.text()).toContain('manager.clickToFinishSetup') + expect(wrapper.text()).toContain('manager.applyChanges') + expect(wrapper.text()).toContain('manager.toFinishSetup') + + // Check Apply Changes button exists + const applyButton = wrapper + .findAll('button') + .find((btn) => btn.text().includes('manager.applyChanges')) + expect(applyButton).toBeTruthy() + + // Check no progress counter + expect(wrapper.text()).not.toMatch(/\d+.*of.*\d+/) + }) + }) + + describe('State 3: Restarting', () => { + it('should display restarting message and spinner during restart', async () => { + // Setup completed state first + mockComfyManagerStore.uncompletedCount = 0 + mockComfyManagerStore.allTasksDone = true + + const wrapper = mountComponent() + + // Click Apply Changes to trigger restart + const applyButton = wrapper + .findAll('button') + .find((btn) => btn.text().includes('manager.applyChanges')) + await applyButton?.trigger('click') + + // Wait for state update + await nextTick() + + // Check restarting message + expect(wrapper.text()).toContain('manager.restartingBackend') + + // Check loading spinner during restart + expect(wrapper.find('.inline-flex').exists()).toBe(true) + + // Check Apply Changes button is hidden + expect(wrapper.text()).not.toContain('manager.applyChanges') + }) + }) + + describe('State 4: Restart Completed', () => { + it('should display success message and auto-close after 3 seconds', async () => { + vi.useFakeTimers() + + // Setup completed state + mockComfyManagerStore.uncompletedCount = 0 + mockComfyManagerStore.allTasksDone = true + + const wrapper = mountComponent() + + // Trigger restart + const applyButton = wrapper + .findAll('button') + .find((btn) => btn.text().includes('manager.applyChanges')) + await applyButton?.trigger('click') + + // Wait for event listener to be set up + await nextTick() + + // Trigger the reconnect handler directly + if (reconnectHandler) { + await reconnectHandler() + } + + // Wait for restart completed state + await nextTick() + + // Check success message + expect(wrapper.text()).toContain('🎉') + expect(wrapper.text()).toContain( + 'manager.extensionsSuccessfullyInstalled' + ) + + // Check dialog closes after 3 seconds + vi.advanceTimersByTime(3000) + + await nextTick() + + expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({ + key: 'global-manager-progress-dialog' + }) + expect(mockComfyManagerStore.clearLogs).toHaveBeenCalled() + + vi.useRealTimers() + }) + }) + + describe('Common Features', () => { + it('should always display close button', async () => { + const wrapper = mountComponent() + + const closeButton = wrapper.find('[aria-label="Close"]') + expect(closeButton.exists()).toBe(true) + }) + + it('should close dialog when close button is clicked', async () => { + const wrapper = mountComponent() + + const closeButton = wrapper.find('[aria-label="Close"]') + await closeButton.trigger('click') + + expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({ + key: 'global-manager-progress-dialog' + }) + }) + }) + + describe('Toast Management', () => { + it('should suppress reconnection toasts during restart', async () => { + mockComfyManagerStore.uncompletedCount = 0 + mockComfyManagerStore.allTasksDone = true + mockSettingStore.get.mockReturnValue(false) // Original setting + + const wrapper = mountComponent() + + // Click Apply Changes + const applyButton = wrapper + .findAll('button') + .find((btn) => btn.text().includes('manager.applyChanges')) + await applyButton?.trigger('click') + + // Check toast setting was disabled + expect(mockSettingStore.set).toHaveBeenCalledWith( + 'Comfy.Toast.DisableReconnectingToast', + true + ) + }) + + it('should restore toast settings after restart completes', async () => { + mockComfyManagerStore.uncompletedCount = 0 + mockComfyManagerStore.allTasksDone = true + mockSettingStore.get.mockReturnValue(false) // Original setting + + const wrapper = mountComponent() + + // Click Apply Changes + const applyButton = wrapper + .findAll('button') + .find((btn) => btn.text().includes('manager.applyChanges')) + await applyButton?.trigger('click') + + // Wait for event listener to be set up + await nextTick() + + // Trigger the reconnect handler directly + if (reconnectHandler) { + await reconnectHandler() + } + + // Wait for settings restoration + await nextTick() + + expect(mockSettingStore.set).toHaveBeenCalledWith( + 'Comfy.Toast.DisableReconnectingToast', + false // Restored to original + ) + }) + }) + + describe('Error Handling', () => { + it('should restore state and close dialog on restart error', async () => { + mockComfyManagerStore.uncompletedCount = 0 + mockComfyManagerStore.allTasksDone = true + + // Mock restart to throw error + mockComfyManagerService.rebootComfyUI.mockRejectedValue( + new Error('Restart failed') + ) + + const wrapper = mountComponent({ captureError: true }) + + // Click Apply Changes + const applyButton = wrapper + .findAll('button') + .find((btn) => btn.text().includes('manager.applyChanges')) + + expect(applyButton).toBeTruthy() + + // The component throws the error but Vue Test Utils catches it + // We need to check if the error handling logic was executed + await applyButton!.trigger('click').catch(() => { + // Error is expected, ignore it + }) + + // Wait for error handling + await nextTick() + + // Check dialog was closed on error + expect(mockDialogStore.closeDialog).toHaveBeenCalled() + // Check toast settings were restored + expect(mockSettingStore.set).toHaveBeenCalledWith( + 'Comfy.Toast.DisableReconnectingToast', + false + ) + // Check that the error handler was called + expect(mockComfyManagerService.rebootComfyUI).toHaveBeenCalled() + }) + }) +}) diff --git a/tests-ui/tests/store/comfyManagerStore.test.ts b/tests-ui/tests/store/comfyManagerStore.test.ts index 41ead35327..f99d2b3632 100644 --- a/tests-ui/tests/store/comfyManagerStore.test.ts +++ b/tests-ui/tests/store/comfyManagerStore.test.ts @@ -6,6 +6,8 @@ import { useComfyManagerService } from '@/services/comfyManagerService' import { useComfyManagerStore } from '@/stores/comfyManagerStore' import { InstalledPacksResponse, + ManagerChannel, + ManagerDatabaseSource, ManagerPackInstalled } from '@/types/comfyManagerTypes' @@ -13,6 +15,34 @@ vi.mock('@/services/comfyManagerService', () => ({ useComfyManagerService: vi.fn() })) +vi.mock('@/services/dialogService', () => ({ + useDialogService: () => ({ + showManagerProgressDialog: vi.fn() + }) +})) + +vi.mock('@/composables/useManagerQueue', () => { + const enqueueTaskMock = vi.fn() + + return { + useManagerQueue: () => ({ + statusMessage: ref(''), + allTasksDone: ref(false), + enqueueTask: enqueueTaskMock, + uncompletedCount: ref(0) + }), + enqueueTask: enqueueTaskMock + } +}) + +vi.mock('@/composables/useServerLogs', () => ({ + useServerLogs: () => ({ + startListening: vi.fn(), + stopListening: vi.fn(), + logs: ref([]) + }) +})) + vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: vi.fn((key) => key) @@ -33,11 +63,7 @@ interface EnabledDisabledTestCase { } describe('useComfyManagerStore', () => { - let mockManagerService: { - isLoading: ReturnType> - error: ReturnType> - listInstalledPacks: ReturnType - } + let mockManagerService: ReturnType const triggerPacksChange = async ( installedPacks: InstalledPacksResponse, @@ -55,10 +81,21 @@ describe('useComfyManagerStore', () => { mockManagerService = { isLoading: ref(false), error: ref(null), - listInstalledPacks: vi.fn().mockResolvedValue({}) + startQueue: vi.fn().mockResolvedValue(null), + resetQueue: vi.fn().mockResolvedValue(null), + getQueueStatus: vi.fn().mockResolvedValue(null), + listInstalledPacks: vi.fn().mockResolvedValue({}), + getImportFailInfo: vi.fn().mockResolvedValue(null), + installPack: vi.fn().mockResolvedValue(null), + uninstallPack: vi.fn().mockResolvedValue(null), + enablePack: vi.fn().mockResolvedValue(null), + disablePack: vi.fn().mockResolvedValue(null), + updatePack: vi.fn().mockResolvedValue(null), + updateAllPacks: vi.fn().mockResolvedValue(null), + rebootComfyUI: vi.fn().mockResolvedValue(null), + isLegacyManagerUI: vi.fn().mockResolvedValue(false) } - // @ts-expect-error Mocking the return type of useComfyManagerService vi.mocked(useComfyManagerService).mockReturnValue(mockManagerService) }) @@ -313,4 +350,90 @@ describe('useComfyManagerStore', () => { } ) }) + + describe('isPackInstalling', () => { + it('should return false for packs not being installed', () => { + const store = useComfyManagerStore() + expect(store.isPackInstalling('test-pack')).toBe(false) + expect(store.isPackInstalling(undefined)).toBe(false) + expect(store.isPackInstalling('')).toBe(false) + }) + + it('should track pack as installing when installPack is called', async () => { + const store = useComfyManagerStore() + + // Call installPack + await store.installPack.call({ + id: 'test-pack', + repository: 'https://github.com/test/test-pack', + channel: ManagerChannel.DEV, + mode: ManagerDatabaseSource.CACHE, + selected_version: 'latest', + version: 'latest' + }) + + // Check that the pack is marked as installing + expect(store.isPackInstalling('test-pack')).toBe(true) + }) + + it('should remove pack from installing list when explicitly removed', async () => { + const store = useComfyManagerStore() + + // Call installPack + await store.installPack.call({ + id: 'test-pack', + repository: 'https://github.com/test/test-pack', + channel: ManagerChannel.DEV, + mode: ManagerDatabaseSource.CACHE, + selected_version: 'latest', + version: 'latest' + }) + + // Verify pack is installing + expect(store.isPackInstalling('test-pack')).toBe(true) + + // Call installPack again for another pack to demonstrate multiple installs + await store.installPack.call({ + id: 'another-pack', + repository: 'https://github.com/test/another-pack', + channel: ManagerChannel.DEV, + mode: ManagerDatabaseSource.CACHE, + selected_version: 'latest', + version: 'latest' + }) + + // Both should be installing + expect(store.isPackInstalling('test-pack')).toBe(true) + expect(store.isPackInstalling('another-pack')).toBe(true) + }) + + it('should track multiple packs installing independently', async () => { + const store = useComfyManagerStore() + + // Install pack 1 + await store.installPack.call({ + id: 'pack-1', + repository: 'https://github.com/test/pack-1', + channel: ManagerChannel.DEV, + mode: ManagerDatabaseSource.CACHE, + selected_version: 'latest', + version: 'latest' + }) + + // Install pack 2 + await store.installPack.call({ + id: 'pack-2', + repository: 'https://github.com/test/pack-2', + channel: ManagerChannel.DEV, + mode: ManagerDatabaseSource.CACHE, + selected_version: 'latest', + version: 'latest' + }) + + // Both should be installing + expect(store.isPackInstalling('pack-1')).toBe(true) + expect(store.isPackInstalling('pack-2')).toBe(true) + expect(store.isPackInstalling('pack-3')).toBe(false) + }) + }) })