diff --git a/src/components/dialog/content/manager/PackVersionBadge.vue b/src/components/dialog/content/manager/PackVersionBadge.vue index ebd6486fa..bd30a91e5 100644 --- a/src/components/dialog/content/manager/PackVersionBadge.vue +++ b/src/components/dialog/content/manager/PackVersionBadge.vue @@ -1,23 +1,85 @@ diff --git a/src/components/dialog/content/manager/PackVersionSelectorPopover.vue b/src/components/dialog/content/manager/PackVersionSelectorPopover.vue new file mode 100644 index 000000000..2e4730d1f --- /dev/null +++ b/src/components/dialog/content/manager/PackVersionSelectorPopover.vue @@ -0,0 +1,126 @@ + + + diff --git a/src/components/dialog/content/manager/__tests__/PackVersionBadge.test.ts b/src/components/dialog/content/manager/__tests__/PackVersionBadge.test.ts new file mode 100644 index 000000000..47c555aff --- /dev/null +++ b/src/components/dialog/content/manager/__tests__/PackVersionBadge.test.ts @@ -0,0 +1,159 @@ +import { VueWrapper, mount } from '@vue/test-utils' +import { createPinia } from 'pinia' +import Button from 'primevue/button' +import PrimeVue from 'primevue/config' +import Popover from 'primevue/popover' +import { describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { createI18n } from 'vue-i18n' + +import enMessages from '@/locales/en/main.json' +import { SelectedVersion } from '@/types/comfyManagerTypes' + +import PackVersionBadge from '../PackVersionBadge.vue' +import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue' + +const mockNodePack = { + id: 'test-pack', + name: 'Test Pack', + latest_version: { + version: '1.0.0' + } +} + +describe('PackVersionBadge', () => { + const mountComponent = ({ + props = {} + }: Record = {}): VueWrapper => { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages } + }) + + return mount(PackVersionBadge, { + props: { + nodePack: mockNodePack, + ...props + }, + global: { + plugins: [PrimeVue, createPinia(), i18n], + components: { + Popover, + PackVersionSelectorPopover + } + } + }) + } + + it('renders with default version (NIGHTLY)', () => { + const wrapper = mountComponent() + + const button = wrapper.findComponent(Button) + expect(button.exists()).toBe(true) + expect(button.props('label')).toBe(SelectedVersion.NIGHTLY) + }) + + it('renders with provided version', () => { + const version = '2.0.0' + const wrapper = mountComponent({ props: { version } }) + + const button = wrapper.findComponent(Button) + expect(button.exists()).toBe(true) + expect(button.props('label')).toBe(version) + }) + + it('shows actual latest (semantic) version prop when version is set to latest', () => { + const wrapper = mountComponent({ + props: { version: SelectedVersion.LATEST } + }) + + const button = wrapper.findComponent(Button) + expect(button.exists()).toBe(true) + expect(button.props('label')).toBe(mockNodePack.latest_version.version) + }) + + it('toggles the popover when button is clicked', async () => { + const wrapper = mountComponent() + + // Spy on the toggle method + const popoverToggleSpy = vi.fn() + const popover = wrapper.findComponent(Popover) + popover.vm.toggle = popoverToggleSpy + + // Open the popover + await wrapper.findComponent(Button).trigger('click') + + // Verify that the toggle method was called + expect(popoverToggleSpy).toHaveBeenCalled() + }) + + it('emits update:version event when version is selected', async () => { + const wrapper = mountComponent() + + // Open the popover + await wrapper.findComponent(Button).trigger('click') + + // Simulate the popover emitting an apply event + wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('apply', '3.0.0') + await nextTick() + + // Check if the update:version event was emitted with the correct value + expect(wrapper.emitted('update:version')).toBeTruthy() + expect(wrapper.emitted('update:version')![0]).toEqual(['3.0.0']) + }) + + it('closes the popover when cancel is clicked', async () => { + const wrapper = mountComponent() + + // Open the popover + await wrapper.findComponent(Button).trigger('click') + + // Simulate the popover emitting a cancel event + wrapper.findComponent(PackVersionSelectorPopover).vm.$emit('cancel') + await nextTick() + + // Check if the popover is hidden + expect(wrapper.findComponent(Popover).isVisible()).toBe(false) + }) + + it('updates displayed version when version prop changes', async () => { + const wrapper = mountComponent({ props: { version: '1.0.0' } }) + + expect(wrapper.findComponent(Button).props('label')).toBe('1.0.0') + + // Update the version prop + await wrapper.setProps({ version: '2.0.0' }) + + // Check if the displayed version was updated + expect(wrapper.findComponent(Button).props('label')).toBe('2.0.0') + }) + + it('handles null or undefined nodePack', async () => { + const wrapper = mountComponent({ props: { nodePack: null } }) + + const button = wrapper.findComponent(Button) + expect(button.exists()).toBe(true) + expect(button.props('label')).toBe(SelectedVersion.NIGHTLY) + + // Should not crash when clicking the button + await button.trigger('click') + expect(wrapper.findComponent(Popover).isVisible()).toBe(false) + }) + + it('handles missing latest_version (unclaimed pack) by falling back to NIGHTLY', async () => { + const incompleteNodePack = { id: 'test-pack', name: 'Test Pack' } + const wrapper = mountComponent({ + props: { + nodePack: incompleteNodePack, + version: SelectedVersion.LATEST + } + }) + + const button = wrapper.findComponent(Button) + expect(button.exists()).toBe(true) + + // Should fallback to nightly string when latest_version is missing + expect(button.props('label')).toBe(SelectedVersion.NIGHTLY) + }) +}) diff --git a/src/components/dialog/content/manager/__tests__/PackVersionSelectorPopover.test.ts b/src/components/dialog/content/manager/__tests__/PackVersionSelectorPopover.test.ts new file mode 100644 index 000000000..9aa1872f0 --- /dev/null +++ b/src/components/dialog/content/manager/__tests__/PackVersionSelectorPopover.test.ts @@ -0,0 +1,190 @@ +import { VueWrapper, mount } from '@vue/test-utils' +import { createPinia } from 'pinia' +import Button from 'primevue/button' +import PrimeVue from 'primevue/config' +import Listbox from 'primevue/listbox' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { createI18n } from 'vue-i18n' + +import enMessages from '@/locales/en/main.json' +import { SelectedVersion } from '@/types/comfyManagerTypes' + +import PackVersionSelectorPopover from '../PackVersionSelectorPopover.vue' + +const mockVersions = [ + { version: '1.0.0', createdAt: '2023-01-01' }, + { version: '0.9.0', createdAt: '2022-12-01' }, + { version: '0.8.0', createdAt: '2022-11-01' } +] + +const mockNodePack = { + id: 'test-pack', + name: 'Test Pack', + latest_version: { version: '1.0.0' } +} + +const mockGetPackVersions = vi.fn().mockResolvedValue(mockVersions) + +vi.mock('@/services/comfyRegistryService', () => ({ + useComfyRegistryService: vi.fn(() => ({ + getPackVersions: mockGetPackVersions + })) +})) + +const waitForPromises = async () => { + await new Promise((resolve) => setTimeout(resolve, 16)) + await nextTick() +} + +describe('PackVersionSelectorPopover', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetPackVersions.mockClear() + mockGetPackVersions.mockResolvedValue(mockVersions) + }) + + const mountComponent = ({ + props = {} + }: Record = {}): VueWrapper => { + const i18n = createI18n({ + legacy: false, + locale: 'en', + messages: { en: enMessages } + }) + + return mount(PackVersionSelectorPopover, { + props: { + nodePack: mockNodePack, + selectedVersion: SelectedVersion.NIGHTLY, + ...props + }, + global: { + plugins: [PrimeVue, createPinia(), i18n], + components: { + Listbox + } + } + }) + } + + it('fetches versions on mount', async () => { + mountComponent() + await waitForPromises() + + expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id) + }) + + it('shows loading state while fetching versions', async () => { + // Delay the promise resolution + mockGetPackVersions.mockImplementationOnce( + () => + new Promise((resolve) => setTimeout(() => resolve(mockVersions), 1000)) + ) + + const wrapper = mountComponent() + + expect(wrapper.text()).toContain('Loading versions...') + }) + + it('displays special options and version options in the listbox', async () => { + const wrapper = mountComponent() + await waitForPromises() + + const listbox = wrapper.findComponent(Listbox) + expect(listbox.exists()).toBe(true) + + const options = listbox.props('options')! + expect(options.length).toBe(5) // 2 special options + 3 version options + + // Check special options + expect(options[0].value).toBe(SelectedVersion.NIGHTLY) + expect(options[1].value).toBe(SelectedVersion.LATEST) + + // Check version options + expect(options[2].value).toBe('1.0.0') + expect(options[3].value).toBe('0.9.0') + expect(options[4].value).toBe('0.8.0') + }) + + it('initializes with the provided selectedVersion prop', async () => { + const selectedVersion = '0.9.0' + const wrapper = mountComponent({ props: { selectedVersion } }) + await waitForPromises() + + // Check that the listbox has the correct model value + const listbox = wrapper.findComponent(Listbox) + expect(listbox.props('modelValue')).toBe(selectedVersion) + }) + + it('emits cancel event when cancel button is clicked', async () => { + const wrapper = mountComponent() + await waitForPromises() + + const cancelButton = wrapper.findAllComponents(Button)[0] + await cancelButton.trigger('click') + + expect(wrapper.emitted('cancel')).toBeTruthy() + }) + + it('emits apply event with current selection when apply button is clicked', async () => { + const selectedVersion = '0.9.0' + const wrapper = mountComponent({ props: { selectedVersion } }) + await waitForPromises() + + const applyButton = wrapper.findAllComponents(Button)[1] + await applyButton.trigger('click') + + expect(wrapper.emitted('apply')).toBeTruthy() + expect(wrapper.emitted('apply')![0]).toEqual([selectedVersion]) + }) + + it('emits apply event with LATEST when no selection and apply button is clicked', async () => { + const wrapper = mountComponent({ props: { selectedVersion: null } }) + await waitForPromises() + + const applyButton = wrapper.findAllComponents(Button)[1] + await applyButton.trigger('click') + + expect(wrapper.emitted('apply')).toBeTruthy() + expect(wrapper.emitted('apply')![0]).toEqual([SelectedVersion.LATEST]) + }) + + it('is reactive to nodePack prop changes', async () => { + const wrapper = mountComponent() + await waitForPromises() + + // Clear mock calls to check if getPackVersions is called again + mockGetPackVersions.mockClear() + + // 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) + }) + + describe('Unclaimed GitHub packs handling', () => { + it('falls back to nightly when comfy-api returns null when fetching versions', async () => { + mockGetPackVersions.mockResolvedValueOnce(null) + + const wrapper = mountComponent() + await waitForPromises() + + const listbox = wrapper.findComponent(Listbox) + expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY) + }) + + it('falls back to nightly when component mounts with no versions (unclaimed pack)', async () => { + mockGetPackVersions.mockResolvedValueOnce([]) + + const wrapper = mountComponent() + await waitForPromises() + + const listbox = wrapper.findComponent(Listbox) + expect(listbox.props('modelValue')).toBe(SelectedVersion.NIGHTLY) + }) + }) +}) diff --git a/src/components/dialog/content/manager/infoPanel/InfoPanel.vue b/src/components/dialog/content/manager/infoPanel/InfoPanel.vue index 26e7f66de..2c17f81e4 100644 --- a/src/components/dialog/content/manager/infoPanel/InfoPanel.vue +++ b/src/components/dialog/content/manager/infoPanel/InfoPanel.vue @@ -21,7 +21,11 @@ /> - +
@@ -32,7 +36,7 @@ diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 8a7be2404..5df7de8f6 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -89,13 +89,18 @@ "name": "Name", "category": "Category", "sort": "Sort", - "filter": "Filter" + "filter": "Filter", + "apply": "Apply" }, "manager": { "title": "Custom Nodes Manager", + "loadingVersions": "Loading versions...", + "selectVersion": "Select Version", "downloads": "Downloads", "repository": "Repository", "license": "License", + "nightlyVersion": "Nightly", + "latestVersion": "Latest", "createdBy": "Created By", "totalNodes": "Total Nodes", "discoverCommunityContent": "Discover community-made Node Packs, Extensions, and more...", diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index b3aa3a544..dce1e56c5 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -117,6 +117,7 @@ "about": "À propos", "add": "Ajouter", "all": "Tout", + "apply": "Appliquer", "back": "Retour", "cancel": "Annuler", "capture": "capture", @@ -383,13 +384,17 @@ }, "installSelected": "Installer sélectionné", "lastUpdated": "Dernière mise à jour", + "latestVersion": "Dernière", "license": "Licence", + "loadingVersions": "Chargement des versions...", + "nightlyVersion": "Nocturne", "noDescription": "Aucune description disponible", "noResultsFound": "Aucun résultat trouvé correspondant à votre recherche.", "nodePack": "Pack de Nœuds", "packsSelected": "Packs sélectionnés", "repository": "Référentiel", "searchPlaceholder": "Recherche", + "selectVersion": "Sélectionner la version", "sort": { "downloads": "Le plus populaire", "rating": "Évaluation" diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index b12f8c61d..1fdc1031c 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -117,6 +117,7 @@ "about": "情報", "add": "追加", "all": "すべて", + "apply": "適用する", "back": "戻る", "cancel": "キャンセル", "capture": "キャプチャ", @@ -383,13 +384,17 @@ }, "installSelected": "選択したものをインストール", "lastUpdated": "最終更新日", + "latestVersion": "最新", "license": "ライセンス", + "loadingVersions": "バージョンを読み込んでいます...", + "nightlyVersion": "ナイトリー", "noDescription": "説明はありません", "noResultsFound": "検索に一致する結果が見つかりませんでした。", "nodePack": "ノードパック", "packsSelected": "選択したパック", "repository": "リポジトリ", "searchPlaceholder": "検索", + "selectVersion": "バージョンを選択", "sort": { "downloads": "最も人気", "rating": "評価" diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 95447e40a..eca9ff39c 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -117,6 +117,7 @@ "about": "정보", "add": "추가", "all": "모두", + "apply": "적용", "back": "뒤로", "cancel": "취소", "capture": "캡처", @@ -383,13 +384,17 @@ }, "installSelected": "선택한 항목 설치", "lastUpdated": "마지막 업데이트", + "latestVersion": "최신", "license": "라이선스", + "loadingVersions": "버전 로딩 중...", + "nightlyVersion": "야간", "noDescription": "설명이 없습니다", "noResultsFound": "검색과 일치하는 결과가 없습니다.", "nodePack": "노드 팩", "packsSelected": "선택한 팩", "repository": "저장소", "searchPlaceholder": "검색", + "selectVersion": "버전 선택", "sort": { "downloads": "가장 인기 있는", "rating": "평점" diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index a01a106b7..fab48d5d1 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -117,6 +117,7 @@ "about": "О программе", "add": "Добавить", "all": "Все", + "apply": "Применить", "back": "Назад", "cancel": "Отмена", "capture": "захват", @@ -383,13 +384,17 @@ }, "installSelected": "Установить выбранное", "lastUpdated": "Последнее обновление", + "latestVersion": "Последняя", "license": "Лицензия", + "loadingVersions": "Загрузка версий...", + "nightlyVersion": "Ночная", "noDescription": "Описание отсутствует", "noResultsFound": "По вашему запросу ничего не найдено.", "nodePack": "Пакет Узлов", "packsSelected": "Выбрано пакетов", "repository": "Репозиторий", "searchPlaceholder": "Поиск", + "selectVersion": "Выберите версию", "sort": { "downloads": "Самые популярные", "rating": "Рейтинг" diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 48ee1a33e..36ac035b1 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -117,6 +117,7 @@ "about": "关于", "add": "添加", "all": "全部", + "apply": "应用", "back": "返回", "cancel": "取消", "capture": "捕获", @@ -383,13 +384,17 @@ }, "installSelected": "安装选定", "lastUpdated": "最后更新", + "latestVersion": "最新", "license": "许可证", + "loadingVersions": "正在加载版本...", + "nightlyVersion": "每夜", "noDescription": "无可用描述", "noResultsFound": "未找到符合您搜索的结果。", "nodePack": "节点包", "packsSelected": "选定的包", "repository": "仓库", "searchPlaceholder": "搜索", + "selectVersion": "选择版本", "sort": { "downloads": "最受欢迎", "rating": "评级"