From b44394fd28ca3d33543a37baa16af79059fa8480 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Tue, 24 Jun 2025 16:51:24 -0700 Subject: [PATCH] [Manager] Add update all button functionality - Add PackUpdateButton component for bulk updates - Add useUpdateAvailableNodes composable to track available updates - Integrate update all button in RegistrySearchBar - Add localization strings for update functionality - Add comprehensive tests for update functionality - Add loading state to PackActionButton --- .../content/manager/ManagerDialogContent.vue | 1 + .../manager/button/PackActionButton.vue | 1 + .../manager/button/PackUpdateButton.vue | 78 ++++ .../registrySearchBar/RegistrySearchBar.vue | 11 + .../nodePack/useUpdateAvailableNodes.ts | 65 ++++ src/locales/en/main.json | 2 + src/locales/es/main.json | 2 + src/locales/fr/main.json | 2 + src/locales/ja/main.json | 2 + src/locales/ko/main.json | 2 + src/locales/ru/main.json | 2 + src/locales/zh/main.json | 2 + .../useUpdateAvailableNodes.test.ts | 358 ++++++++++++++++++ 13 files changed, 528 insertions(+) create mode 100644 src/components/dialog/content/manager/button/PackUpdateButton.vue create mode 100644 src/composables/nodePack/useUpdateAvailableNodes.ts create mode 100644 tests-ui/tests/composables/useUpdateAvailableNodes.test.ts diff --git a/src/components/dialog/content/manager/ManagerDialogContent.vue b/src/components/dialog/content/manager/ManagerDialogContent.vue index f632e3555..5cd6c42e3 100644 --- a/src/components/dialog/content/manager/ManagerDialogContent.vue +++ b/src/components/dialog/content/manager/ManagerDialogContent.vue @@ -34,6 +34,7 @@ :suggestions="suggestions" :is-missing-tab="isMissingTab" :sort-options="sortOptions" + :is-update-available-tab="isUpdateAvailableTab" />
+ + + + diff --git a/src/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue b/src/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue index 90e13b54d..904d43264 100644 --- a/src/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue +++ b/src/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue @@ -33,6 +33,10 @@ :node-packs="missingNodePacks" :label="$t('manager.installAllMissingNodes')" /> +
@@ -65,8 +69,10 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue' +import PackUpdateButton from '@/components/dialog/content/manager/button/PackUpdateButton.vue' import SearchFilterDropdown from '@/components/dialog/content/manager/registrySearchBar/SearchFilterDropdown.vue' import { useMissingNodes } from '@/composables/nodePack/useMissingNodes' +import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes' import { type SearchOption, SortableAlgoliaField @@ -83,6 +89,7 @@ const { searchResults, sortOptions } = defineProps<{ suggestions?: QuerySuggestion[] sortOptions?: SortableField[] isMissingTab?: boolean + isUpdateAvailableTab?: boolean }>() const searchQuery = defineModel('searchQuery') @@ -96,6 +103,10 @@ const { t } = useI18n() // Get missing node packs from workflow with loading and error states const { missingNodePacks, isLoading, error } = useMissingNodes() +// Use the composable to get update available nodes +const { hasUpdateAvailable, updateAvailableNodePacks } = + useUpdateAvailableNodes() + const hasResults = computed( () => searchQuery.value?.trim() && searchResults?.length ) diff --git a/src/composables/nodePack/useUpdateAvailableNodes.ts b/src/composables/nodePack/useUpdateAvailableNodes.ts new file mode 100644 index 000000000..516ffffdb --- /dev/null +++ b/src/composables/nodePack/useUpdateAvailableNodes.ts @@ -0,0 +1,65 @@ +import { computed, onMounted } from 'vue' + +import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks' +import { useComfyManagerStore } from '@/stores/comfyManagerStore' +import type { components } from '@/types/comfyRegistryTypes' +import { compareVersions, isSemVer } from '@/utils/formatUtil' + +/** + * Composable to find NodePacks that have updates available + * Uses the same filtering approach as ManagerDialogContent.vue + * Automatically fetches installed pack data when initialized + */ +export const useUpdateAvailableNodes = () => { + const comfyManagerStore = useComfyManagerStore() + const { installedPacks, isLoading, error, startFetchInstalled } = + useInstalledPacks() + + // Check if a pack has updates available (same logic as usePackUpdateStatus) + const isOutdatedPack = (pack: components['schemas']['Node']) => { + const isInstalled = comfyManagerStore.isPackInstalled(pack?.id) + if (!isInstalled) return false + + const installedVersion = comfyManagerStore.getInstalledPackVersion( + pack.id ?? '' + ) + const latestVersion = pack.latest_version?.version + + const isNightlyPack = !!installedVersion && !isSemVer(installedVersion) + + if (isNightlyPack || !latestVersion) { + return false + } + + return compareVersions(latestVersion, installedVersion) > 0 + } + + // Same filtering logic as ManagerDialogContent.vue + const filterOutdatedPacks = (packs: components['schemas']['Node'][]) => + packs.filter(isOutdatedPack) + + // Filter only outdated packs from installed packs + const updateAvailableNodePacks = computed(() => { + if (!installedPacks.value.length) return [] + return filterOutdatedPacks(installedPacks.value) + }) + + // Check if there are any outdated packs + const hasUpdateAvailable = computed(() => { + return updateAvailableNodePacks.value.length > 0 + }) + + // Automatically fetch installed pack data when composable is used + onMounted(async () => { + if (!installedPacks.value.length && !isLoading.value) { + await startFetchInstalled() + } + }) + + return { + updateAvailableNodePacks, + hasUpdateAvailable, + isLoading, + error + } +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index f281a75db..09cc75d86 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -148,6 +148,8 @@ "uninstalling": "Uninstalling", "update": "Update", "uninstallSelected": "Uninstall Selected", + "updateSelected": "Update Selected", + "updateAll": "Update All", "updatingAllPacks": "Updating all packages", "license": "License", "nightlyVersion": "Nightly", diff --git a/src/locales/es/main.json b/src/locales/es/main.json index d0577101f..2051d680b 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -635,6 +635,8 @@ "uninstallSelected": "Desinstalar Seleccionado", "uninstalling": "Desinstalando", "update": "Actualizar", + "updateSelected": "Actualizar seleccionados", + "updateAll": "Actualizar todo", "updatingAllPacks": "Actualizando todos los paquetes", "version": "Versión" }, diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index cebc51480..0fcab0a16 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -635,6 +635,8 @@ "uninstallSelected": "Désinstaller sélectionné", "uninstalling": "Désinstallation", "update": "Mettre à jour", + "updateSelected": "Mettre à jour la sélection", + "updateAll": "Tout mettre à jour", "updatingAllPacks": "Mise à jour de tous les paquets", "version": "Version" }, diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index a1ca9fb9c..09e772478 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -635,6 +635,8 @@ "uninstallSelected": "選択したものをアンインストール", "uninstalling": "アンインストール中", "update": "更新", + "updateSelected": "選択を更新", + "updateAll": "すべて更新", "updatingAllPacks": "すべてのパッケージを更新中", "version": "バージョン" }, diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 99feab40f..634327329 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -635,6 +635,8 @@ "uninstallSelected": "선택 항목 제거", "uninstalling": "제거 중", "update": "업데이트", + "updateSelected": "선택 항목 업데이트", + "updateAll": "전체 업데이트", "updatingAllPacks": "모든 패키지 업데이트 중", "version": "버전" }, diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index 5f416ec61..462abdff0 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -635,6 +635,8 @@ "uninstallSelected": "Удалить выбранное", "uninstalling": "Удаление", "update": "Обновить", + "updateSelected": "Обновить выбранное", + "updateAll": "Обновить все", "updatingAllPacks": "Обновление всех пакетов", "version": "Версия" }, diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 97baf5115..0f7499e9d 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -635,6 +635,8 @@ "uninstallSelected": "卸载所选", "uninstalling": "正在卸载", "update": "更新", + "updateSelected": "更新所选", + "updateAll": "全部更新", "updatingAllPacks": "更新所有包", "version": "版本" }, diff --git a/tests-ui/tests/composables/useUpdateAvailableNodes.test.ts b/tests-ui/tests/composables/useUpdateAvailableNodes.test.ts new file mode 100644 index 000000000..392f9b878 --- /dev/null +++ b/tests-ui/tests/composables/useUpdateAvailableNodes.test.ts @@ -0,0 +1,358 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, ref } from 'vue' + +import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks' +import { useUpdateAvailableNodes } from '@/composables/nodePack/useUpdateAvailableNodes' +import { useComfyManagerStore } from '@/stores/comfyManagerStore' +// Import mocked utils +import { compareVersions, isSemVer } from '@/utils/formatUtil' + +// Mock Vue's onMounted to execute immediately for testing +vi.mock('vue', async () => { + const actual = await vi.importActual('vue') + return { + ...actual, + onMounted: (cb: () => void) => cb() + } +}) + +// Mock the dependencies +vi.mock('@/composables/nodePack/useInstalledPacks', () => ({ + useInstalledPacks: vi.fn() +})) + +vi.mock('@/stores/comfyManagerStore', () => ({ + useComfyManagerStore: vi.fn() +})) + +vi.mock('@/utils/formatUtil', () => ({ + compareVersions: vi.fn(), + isSemVer: vi.fn() +})) + +const mockUseInstalledPacks = vi.mocked(useInstalledPacks) +const mockUseComfyManagerStore = vi.mocked(useComfyManagerStore) + +const mockCompareVersions = vi.mocked(compareVersions) +const mockIsSemVer = vi.mocked(isSemVer) + +describe('useUpdateAvailableNodes', () => { + const mockInstalledPacks = [ + { + id: 'pack-1', + name: 'Outdated Pack', + latest_version: { version: '2.0.0' } + }, + { + id: 'pack-2', + name: 'Up to Date Pack', + latest_version: { version: '1.0.0' } + }, + { + id: 'pack-3', + name: 'Nightly Pack', + latest_version: { version: '1.5.0' } + }, + { + id: 'pack-4', + name: 'No Latest Version', + latest_version: null + } + ] + + const mockStartFetchInstalled = vi.fn() + const mockIsPackInstalled = vi.fn() + const mockGetInstalledPackVersion = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + + // Default setup + mockIsPackInstalled.mockReturnValue(true) + mockGetInstalledPackVersion.mockImplementation((id: string) => { + switch (id) { + case 'pack-1': + return '1.0.0' // outdated + case 'pack-2': + return '1.0.0' // up to date + case 'pack-3': + return 'nightly-abc123' // nightly + case 'pack-4': + return '1.0.0' // no latest version + default: + return '1.0.0' + } + }) + + mockIsSemVer.mockImplementation((version: string) => { + return !version.includes('nightly') + }) + + mockCompareVersions.mockImplementation( + (latest: string | undefined, installed: string | undefined) => { + if (latest === '2.0.0' && installed === '1.0.0') return 1 // outdated + if (latest === '1.0.0' && installed === '1.0.0') return 0 // up to date + return 0 + } + ) + + mockUseComfyManagerStore.mockReturnValue({ + isPackInstalled: mockIsPackInstalled, + getInstalledPackVersion: mockGetInstalledPackVersion + } as any) + + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref([]), + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + }) + + describe('core filtering logic', () => { + it('identifies outdated packs correctly', () => { + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref(mockInstalledPacks), + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { updateAvailableNodePacks } = useUpdateAvailableNodes() + + // Should only include pack-1 (outdated) + expect(updateAvailableNodePacks.value).toHaveLength(1) + expect(updateAvailableNodePacks.value[0].id).toBe('pack-1') + }) + + it('excludes up-to-date packs', () => { + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { updateAvailableNodePacks } = useUpdateAvailableNodes() + + expect(updateAvailableNodePacks.value).toHaveLength(0) + }) + + it('excludes nightly packs from updates', () => { + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { updateAvailableNodePacks } = useUpdateAvailableNodes() + + expect(updateAvailableNodePacks.value).toHaveLength(0) + }) + + it('excludes packs with no latest version', () => { + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref([mockInstalledPacks[3]]), // pack-4: no latest version + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { updateAvailableNodePacks } = useUpdateAvailableNodes() + + expect(updateAvailableNodePacks.value).toHaveLength(0) + }) + + it('excludes uninstalled packs', () => { + mockIsPackInstalled.mockReturnValue(false) + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref(mockInstalledPacks), + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { updateAvailableNodePacks } = useUpdateAvailableNodes() + + expect(updateAvailableNodePacks.value).toHaveLength(0) + }) + + it('returns empty array when no installed packs exist', () => { + const { updateAvailableNodePacks } = useUpdateAvailableNodes() + + expect(updateAvailableNodePacks.value).toEqual([]) + }) + }) + + describe('hasUpdateAvailable computed', () => { + it('returns true when updates are available', () => { + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { hasUpdateAvailable } = useUpdateAvailableNodes() + + expect(hasUpdateAvailable.value).toBe(true) + }) + + it('returns false when no updates are available', () => { + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { hasUpdateAvailable } = useUpdateAvailableNodes() + + expect(hasUpdateAvailable.value).toBe(false) + }) + }) + + describe('automatic data fetching', () => { + it('fetches installed packs automatically when none exist', () => { + useUpdateAvailableNodes() + + expect(mockStartFetchInstalled).toHaveBeenCalledOnce() + }) + + it('does not fetch when packs already exist', () => { + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref(mockInstalledPacks), + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + useUpdateAvailableNodes() + + expect(mockStartFetchInstalled).not.toHaveBeenCalled() + }) + + it('does not fetch when already loading', () => { + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref([]), + isLoading: ref(true), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + useUpdateAvailableNodes() + + expect(mockStartFetchInstalled).not.toHaveBeenCalled() + }) + }) + + describe('state management', () => { + it('exposes loading state from useInstalledPacks', () => { + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref([]), + isLoading: ref(true), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { isLoading } = useUpdateAvailableNodes() + + expect(isLoading.value).toBe(true) + }) + + it('exposes error state from useInstalledPacks', () => { + const testError = 'Failed to fetch installed packs' + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref([]), + isLoading: ref(false), + error: ref(testError), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { error } = useUpdateAvailableNodes() + + expect(error.value).toBe(testError) + }) + }) + + describe('reactivity', () => { + it('updates when installed packs change', async () => { + const installedPacksRef = ref([]) + mockUseInstalledPacks.mockReturnValue({ + installedPacks: installedPacksRef, + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { updateAvailableNodePacks, hasUpdateAvailable } = + useUpdateAvailableNodes() + + // Initially empty + expect(updateAvailableNodePacks.value).toEqual([]) + expect(hasUpdateAvailable.value).toBe(false) + + // Update installed packs + installedPacksRef.value = [mockInstalledPacks[0]] as any // pack-1: outdated + await nextTick() + + // Should update available updates + expect(updateAvailableNodePacks.value).toHaveLength(1) + expect(hasUpdateAvailable.value).toBe(true) + }) + }) + + describe('version comparison logic', () => { + it('calls compareVersions with correct parameters', () => { + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref([mockInstalledPacks[0]]), // pack-1 + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { updateAvailableNodePacks } = useUpdateAvailableNodes() + + // Access the computed to trigger the logic + expect(updateAvailableNodePacks.value).toBeDefined() + + expect(mockCompareVersions).toHaveBeenCalledWith('2.0.0', '1.0.0') + }) + + it('calls isSemVer to check nightly versions', () => { + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref([mockInstalledPacks[2]]), // pack-3: nightly + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { updateAvailableNodePacks } = useUpdateAvailableNodes() + + // Access the computed to trigger the logic + expect(updateAvailableNodePacks.value).toBeDefined() + + expect(mockIsSemVer).toHaveBeenCalledWith('nightly-abc123') + }) + + it('calls isPackInstalled for each pack', () => { + mockUseInstalledPacks.mockReturnValue({ + installedPacks: ref(mockInstalledPacks), + isLoading: ref(false), + error: ref(null), + startFetchInstalled: mockStartFetchInstalled + } as any) + + const { updateAvailableNodePacks } = useUpdateAvailableNodes() + + // Access the computed to trigger the logic + expect(updateAvailableNodePacks.value).toBeDefined() + + expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-1') + expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-2') + expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-3') + expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-4') + }) + }) +})