Compare commits

...

17 Commits

Author SHA1 Message Date
Christian Byrne
9cd29d6d4d [Release] v1.26.12 (#5641)
## What's Changed

### 🐛 Bug Fixes
- Change manager flag from --disable-manager to --enable-manager to
align with backend changes (#5635)

This hotfix ensures frontend compatibility with ComfyUI core PR #7555,
changing the manager startup behavior from opt-out to opt-in.

**Full Changelog**:
https://github.com/Comfy-Org/ComfyUI_frontend/compare/v1.26.11...v1.26.12
EOF < /dev/null

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5641-Release-v1-26-12-2726d73d36508141aae1efa8f2bc4b08)
by [Unito](https://www.unito.io)
2025-09-18 14:27:22 -07:00
Comfy Org PR Bot
766b3b87ca [backport 1.26] refactor: Change manager flag from --disable-manager to --enable-manager (#5639)
Backport of #5635 to `core/1.26`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5639-backport-1-26-refactor-Change-manager-flag-from-disable-manager-to-enable-manager-2726d73d36508120a29fcaefd91dbf40)
by [Unito](https://www.unito.io)

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2025-09-18 13:22:26 -07:00
Benjamin Lu
ca4352acb4 Bump to 1.26.11 (#5509) 2025-09-11 20:51:28 -07:00
Benjamin Lu
e773816406 Lower to 1.26.10 (#5508) 2025-09-11 20:48:06 -07:00
Benjamin Lu
902dd9f95d trigger release (#5505) 2025-09-11 19:13:54 -07:00
Benjamin Lu
04b28cb107 it's over 10! (#5504) 2025-09-11 18:16:19 -07:00
Benjamin Lu
114cdb592a Manually Backport #5500 (#5502)
* Manually backport https://github.com/Comfy-Org/ComfyUI_frontend/pull/5500

* why is ci not running
2025-09-11 18:13:54 -07:00
Benjamin Lu
959ede2529 1.26.10 (#5490) 2025-09-11 01:40:42 -07:00
Comfy Org PR Bot
132e98b85e feat: Auto-close LoadWorkflowWarning dialog when all missing nodes are installed (#5321) (#5487)
* feat: Auto-close LoadWorkflowWarning dialog when all missing nodes are installed

- Add computed property to check if all missing nodes are installed
- Watch for completion and automatically close dialog with 500ms delay
- Show success toast notification when installation completes
- Add translation key for success message

This improves UX by automatically dismissing the warning dialog once the user has successfully installed all missing nodes through the manager.

* fix: settimeout to nexttick

* [auto-fix] Apply ESLint and Prettier fixes

---------

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2025-09-11 00:06:04 -07:00
Comfy Org PR Bot
5d1cbd5612 [feat] Improve UX for disabled node packs in Manager dialog (#5478) (#5485)
* [feat] Improve UX for disabled node packs in Manager dialog

- Hide "Update All" button when only disabled packs have updates
- Add tooltip on "Update All" hover to indicate disabled nodes won't be updated
- Disable version selector and show tooltip for disabled node packs
- Filter updates to only show enabled packs in the update queue
- Add visual indicators (opacity, cursor) for disabled pack cards
- Add comprehensive test coverage for new functionality

This improves the user experience by clearly indicating which packs
can be updated and preventing confusion about disabled packs.

🤖 Generated with [Claude Code](https://claude.ai/code)



* chore: missing nodes description added

* test: test code modified

---------

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-10 22:50:58 -07:00
Comfy Org PR Bot
5befd00dfc add pricing for new ByteDance node (#5481) (#5483)
Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-09-10 20:17:35 -07:00
Comfy Org PR Bot
75e5089546 update prices for Veo3 (#5418) (#5420)
Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-09-07 14:38:49 -07:00
Comfy Org PR Bot
b9881fac29 Fix version detection for disabled packs (#5395) (#5416)
* fix: normalize pack IDs to fix version detection for disabled packs

When a pack is disabled, ComfyUI-Manager returns it with a version suffix
(e.g., "ComfyUI-GGUF@1_1_4") while enabled packs don't have this suffix.
This inconsistency caused disabled packs to incorrectly show as having
updates available even when they were on the latest version.

Changes:
- Add normalizePackId utility to consistently remove version suffixes
- Apply normalization in refreshInstalledList and WebSocket updates
- Use the utility across conflict detection and node help modules
- Ensure pack version info is preserved in the object's ver field

This fixes the "Update Available" indicator incorrectly showing for
disabled packs that are already on the latest version.

🤖 Generated with [Claude Code](https://claude.ai/code)



* feature: test code added

* test: packUtils test code added

* test: address PR review feedback for test
  improvements

  - Remove unnecessary .not.toThrow() assertion
  in useManagerQueue test
  - Add clarifying comments for version
  normalization test logic
  - Replace 'as any' with vi.mocked() for better
  type safety

---------

Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-07 00:48:00 -07:00
Comfy Org PR Bot
a51e228e44 [bugfix] Fix manager dialog warning banner close button visibility (#5397) (#5414)
* feature: manager banner style fix

* fix: light-theme color

* fix: icon color modified for dark theme

Co-authored-by: Jin Yi <jin12cc@gmail.com>
2025-09-06 21:58:57 -07:00
Jin Yi
4f01333e74 fix: feature flags and manager state handling (#5317) - merge conflict resolve (#5410) 2025-09-06 20:24:51 -07:00
Comfy Org PR Bot
2bb158c51c fix: packEnable button added hasConflict props (#5392) (#5402)
Co-authored-by: Jin Yi <jin12cc@gmail.com>
2025-09-06 13:39:47 -07:00
Comfy Org PR Bot
fdbf476179 Fix/toolbox node detection (#5361) (#5375)
* refactor: dont need will change on animations

* fix: by disabling parent pointer events and forcing on child element

* fix: color picker watch with immediate option

* Update test expectations [skip ci]

---------

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: Jake Schroeder <jake.schroeder@isophex.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-09-05 19:03:46 -07:00
51 changed files with 1665 additions and 634 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

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

View File

@@ -15,10 +15,10 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue' import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import { useConflictDetection } from '@/composables/useConflictDetection'
import config from '@/config' import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useConflictDetection } from './composables/useConflictDetection'
import { electronAPI, isElectron } from './utils/envUtil' import { electronAPI, isElectron } from './utils/envUtil'
const workspaceStore = useWorkspaceStore() const workspaceStore = useWorkspaceStore()

View File

@@ -1,5 +1,11 @@
<template> <template>
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick"> <Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<slot></slot> <slot></slot>
</Button> </Button>
</template> </template>
@@ -20,6 +26,10 @@ interface IconButtonProps extends BaseButtonProps {
onClick: (event: Event) => void onClick: (event: Event) => void
} }
defineOptions({
inheritAttrs: false
})
const { const {
size = 'md', size = 'md',
type = 'secondary', type = 'secondary',

View File

@@ -1,5 +1,11 @@
<template> <template>
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick"> <Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<slot v-if="iconPosition !== 'right'" name="icon"></slot> <slot v-if="iconPosition !== 'right'" name="icon"></slot>
<span>{{ label }}</span> <span>{{ label }}</span>
<slot v-if="iconPosition === 'right'" name="icon"></slot> <slot v-if="iconPosition === 'right'" name="icon"></slot>
@@ -18,6 +24,10 @@ import {
getButtonTypeClasses getButtonTypeClasses
} from '@/types/buttonTypes' } from '@/types/buttonTypes'
defineOptions({
inheritAttrs: false
})
interface IconTextButtonProps extends BaseButtonProps { interface IconTextButtonProps extends BaseButtonProps {
iconPosition?: 'left' | 'right' iconPosition?: 'left' | 'right'
label: string label: string

View File

@@ -1,5 +1,11 @@
<template> <template>
<Button unstyled :class="buttonStyle" :disabled="disabled" @click="onClick"> <Button
v-bind="$attrs"
unstyled
:class="buttonStyle"
:disabled="disabled"
@click="onClick"
>
<span>{{ label }}</span> <span>{{ label }}</span>
</Button> </Button>
</template> </template>
@@ -21,6 +27,10 @@ interface TextButtonProps extends BaseButtonProps {
onClick: () => void onClick: () => void
} }
defineOptions({
inheritAttrs: false
})
const { const {
size = 'md', size = 'md',
type = 'primary', type = 'primary',

View File

@@ -105,7 +105,7 @@ const showContactSupport = async () => {
onMounted(async () => { onMounted(async () => {
if (!systemStatsStore.systemStats) { if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats() await systemStatsStore.refetchSystemStats()
} }
try { try {

View File

@@ -2,8 +2,8 @@
<NoResultsPlaceholder <NoResultsPlaceholder
class="pb-0" class="pb-0"
icon="pi pi-exclamation-circle" icon="pi pi-exclamation-circle"
title="Some Nodes Are Missing" :title="$t('loadWorkflowWarning.missingNodesTitle')"
message="When loading the graph, the following node types were not found" :message="$t('loadWorkflowWarning.missingNodesDescription')"
/> />
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" /> <MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
<ListBox <ListBox
@@ -53,19 +53,15 @@
<script setup lang="ts"> <script setup lang="ts">
import Button from 'primevue/button' import Button from 'primevue/button'
import ListBox from 'primevue/listbox' import ListBox from 'primevue/listbox'
import { computed } from 'vue' import { computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue' import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes' import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useDialogService } from '@/services/dialogService' import { useManagerState } from '@/composables/useManagerState'
import { useComfyManagerStore } from '@/stores/comfyManagerStore' import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useCommandStore } from '@/stores/commandStore' import { useDialogStore } from '@/stores/dialogStore'
import {
ManagerUIState,
useManagerStateStore
} from '@/stores/managerStateStore'
import { useToastStore } from '@/stores/toastStore' import { useToastStore } from '@/stores/toastStore'
import type { MissingNodeType } from '@/types/comfy' import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes' import { ManagerTab } from '@/types/comfyManagerTypes'
@@ -81,6 +77,7 @@ const { missingNodePacks, isLoading, error, missingCoreNodes } =
useMissingNodes() useMissingNodes()
const comfyManagerStore = useComfyManagerStore() const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()
// Check if any of the missing packs are currently being installed // Check if any of the missing packs are currently being installed
const isInstalling = computed(() => { const isInstalling = computed(() => {
@@ -111,48 +108,51 @@ const uniqueNodes = computed(() => {
}) })
}) })
const managerStateStore = useManagerStateStore()
// Show manager buttons unless manager is disabled // Show manager buttons unless manager is disabled
const showManagerButtons = computed(() => { const showManagerButtons = computed(() => {
return managerStateStore.managerUIState !== ManagerUIState.DISABLED return managerState.shouldShowManagerButtons.value
}) })
// Only show Install All button for NEW_UI (new manager with v4 support) // Only show Install All button for NEW_UI (new manager with v4 support)
const showInstallAllButton = computed(() => { const showInstallAllButton = computed(() => {
return managerStateStore.managerUIState === ManagerUIState.NEW_UI return managerState.shouldShowInstallButton.value
}) })
const openManager = async () => { const openManager = async () => {
const state = managerStateStore.managerUIState await managerState.openManager({
initialTab: ManagerTab.Missing,
switch (state) { showToastOnLegacyError: true
case ManagerUIState.DISABLED: })
useDialogService().showSettingsDialog('extension')
break
case ManagerUIState.LEGACY_UI:
try {
await useCommandStore().execute('Comfy.Manager.Menu.ToggleVisibility')
} catch {
// If legacy command doesn't exist, show toast
const { t } = useI18n()
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
}
break
case ManagerUIState.NEW_UI:
useDialogService().showManagerDialog({
initialTab: ManagerTab.Missing
})
break
}
} }
const { t } = useI18n()
const dialogStore = useDialogStore()
// Computed to check if all missing nodes have been installed
const allMissingNodesInstalled = computed(() => {
return (
!isLoading.value &&
!isInstalling.value &&
missingNodePacks.value?.length === 0
)
})
// Watch for completion and close dialog
watch(allMissingNodesInstalled, async (allInstalled) => {
if (allInstalled) {
// Use nextTick to ensure state updates are complete
await nextTick()
dialogStore.closeDialog({ key: 'global-load-workflow-warning' })
// Show success toast
useToastStore().add({
severity: 'success',
summary: t('g.success'),
detail: t('manager.allMissingNodesInstalled'),
life: 3000
})
}
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -42,9 +42,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { whenever } from '@vueuse/core'
import Message from 'primevue/message' import Message from 'primevue/message'
import { computed, ref } from 'vue' import { computed } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSystemStatsStore } from '@/stores/systemStatsStore' import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -60,20 +59,11 @@ const hasMissingCoreNodes = computed(() => {
return Object.keys(props.missingCoreNodes).length > 0 return Object.keys(props.missingCoreNodes).length > 0
}) })
const currentComfyUIVersion = ref<string | null>(null) // Use computed for reactive version tracking
whenever( const currentComfyUIVersion = computed<string | null>(() => {
hasMissingCoreNodes, if (!hasMissingCoreNodes.value) return null
async () => { return systemStatsStore.systemStats?.system?.comfyui_version ?? null
if (!systemStatsStore.systemStats) { })
await systemStatsStore.fetchSystemStats()
}
currentComfyUIVersion.value =
systemStatsStore.systemStats?.system?.comfyui_version ?? null
},
{
immediate: true
}
)
const sortedMissingCoreNodes = computed(() => { const sortedMissingCoreNodes = computed(() => {
return Object.entries(props.missingCoreNodes).sort(([a], [b]) => { return Object.entries(props.missingCoreNodes).sort(([a], [b]) => {

View File

@@ -29,7 +29,7 @@
<!-- Conflict Warning Banner --> <!-- Conflict Warning Banner -->
<div <div
v-if="shouldShowManagerBanner" v-if="shouldShowManagerBanner"
class="bg-yellow-600 bg-opacity-20 border border-yellow-400 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6 relative" class="bg-yellow-500/20 rounded-lg p-4 mt-3 mb-4 flex items-center gap-6 relative"
> >
<i class="pi pi-exclamation-triangle text-yellow-600 text-lg"></i> <i class="pi pi-exclamation-triangle text-yellow-600 text-lg"></i>
<div class="flex flex-col gap-2 flex-1"> <div class="flex flex-col gap-2 flex-1">
@@ -46,14 +46,15 @@
{{ $t('manager.conflicts.warningBanner.button') }} {{ $t('manager.conflicts.warningBanner.button') }}
</p> </p>
</div> </div>
<button <IconButton
type="button" class="absolute top-0 right-0"
class="absolute top-2 right-2 w-6 h-6 border-none outline-none bg-transparent flex items-center justify-center text-yellow-600 rounded transition-colors" type="transparent"
:aria-label="$t('g.close')"
@click="dismissWarningBanner" @click="dismissWarningBanner"
> >
<i class="pi pi-times text-sm"></i> <i
</button> class="pi pi-times text-neutral-900 dark-theme:text-white text-xs"
></i>
</IconButton>
</div> </div>
<RegistrySearchBar <RegistrySearchBar
v-model:searchQuery="searchQuery" v-model:searchQuery="searchQuery"
@@ -138,6 +139,7 @@ import {
} from 'vue' } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import ContentDivider from '@/components/common/ContentDivider.vue' import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue' import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue' import VirtualGrid from '@/components/common/VirtualGrid.vue'

View File

@@ -1,6 +1,7 @@
import { VueWrapper, mount } from '@vue/test-utils' import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config' import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n' import { createI18n } from 'vue-i18n'
@@ -31,11 +32,14 @@ const mockInstalledPacks = {
'installed-pack': { ver: '2.0.0' } 'installed-pack': { ver: '2.0.0' }
} }
const mockIsPackEnabled = vi.fn(() => true)
vi.mock('@/stores/comfyManagerStore', () => ({ vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({ useComfyManagerStore: vi.fn(() => ({
installedPacks: mockInstalledPacks, installedPacks: mockInstalledPacks,
isPackInstalled: (id: string) => isPackInstalled: (id: string) =>
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks] !!mockInstalledPacks[id as keyof typeof mockInstalledPacks],
isPackEnabled: mockIsPackEnabled
})) }))
})) }))
@@ -60,6 +64,7 @@ describe('PackVersionBadge', () => {
beforeEach(() => { beforeEach(() => {
mockToggle.mockReset() mockToggle.mockReset()
mockHide.mockReset() mockHide.mockReset()
mockIsPackEnabled.mockReturnValue(true) // Reset to default enabled state
}) })
const mountComponent = ({ const mountComponent = ({
@@ -79,6 +84,9 @@ describe('PackVersionBadge', () => {
}, },
global: { global: {
plugins: [PrimeVue, createPinia(), i18n], plugins: [PrimeVue, createPinia(), i18n],
directives: {
tooltip: Tooltip
},
stubs: { stubs: {
Popover: PopoverStub, Popover: PopoverStub,
PackVersionSelectorPopover: true PackVersionSelectorPopover: true
@@ -229,4 +237,63 @@ describe('PackVersionBadge', () => {
expect(mockHide).not.toHaveBeenCalled() expect(mockHide).not.toHaveBeenCalled()
}) })
}) })
describe('disabled state', () => {
beforeEach(() => {
mockIsPackEnabled.mockReturnValue(false) // Set all packs as disabled for these tests
})
it('adds disabled styles when pack is disabled', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
expect(badge.classes()).toContain('cursor-not-allowed')
expect(badge.classes()).toContain('opacity-60')
})
it('does not show chevron icon when disabled', () => {
const wrapper = mountComponent()
const chevronIcon = wrapper.find('.pi-chevron-right')
expect(chevronIcon.exists()).toBe(false)
})
it('does not show update arrow when disabled', () => {
const wrapper = mountComponent()
const updateIcon = wrapper.find('.pi-arrow-circle-up')
expect(updateIcon.exists()).toBe(false)
})
it('does not toggle popover when clicked while disabled', async () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
await badge.trigger('click')
// Since it's disabled, the popover should not be toggled
expect(mockToggle).not.toHaveBeenCalled()
})
it('has correct tabindex when disabled', () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
expect(badge.attributes('tabindex')).toBe('-1')
})
it('does not respond to keyboard events when disabled', async () => {
const wrapper = mountComponent()
const badge = wrapper.find('[role="text"]') // role changes to "text" when disabled
expect(badge.exists()).toBe(true)
await badge.trigger('keydown.enter')
await badge.trigger('keydown.space')
expect(mockToggle).not.toHaveBeenCalled()
})
})
}) })

View File

@@ -1,21 +1,28 @@
<template> <template>
<div> <div>
<div <div
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer py-1" v-tooltip.top="
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill }" isDisabled ? $t('manager.enablePackToChangeVersion') : null
aria-haspopup="true" "
role="button" class="inline-flex items-center gap-1 rounded-2xl text-xs py-1"
tabindex="0" :class="{
@click="toggleVersionSelector" 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill,
@keydown.enter="toggleVersionSelector" 'cursor-pointer': !isDisabled,
@keydown.space="toggleVersionSelector" 'cursor-not-allowed opacity-60': isDisabled
}"
:aria-haspopup="!isDisabled"
:role="isDisabled ? 'text' : 'button'"
:tabindex="isDisabled ? -1 : 0"
@click="!isDisabled && toggleVersionSelector($event)"
@keydown.enter="!isDisabled && toggleVersionSelector($event)"
@keydown.space="!isDisabled && toggleVersionSelector($event)"
> >
<i <i
v-if="isUpdateAvailable" v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600 text-xs" class="pi pi-arrow-circle-up text-blue-600 text-xs"
/> />
<span>{{ installedVersion }}</span> <span>{{ installedVersion }}</span>
<i class="pi pi-chevron-right text-xxs" /> <i v-if="!isDisabled" class="pi pi-chevron-right text-xxs" />
</div> </div>
<Popover <Popover
@@ -61,6 +68,11 @@ const popoverRef = ref()
const managerStore = useComfyManagerStore() const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !managerStore.isPackEnabled(nodePack?.id)
)
const installedVersion = computed(() => { const installedVersion = computed(() => {
if (!nodePack.id) return 'nightly' if (!nodePack.id) return 'nightly'
const version = const version =

View File

@@ -1,5 +1,8 @@
<template> <template>
<IconTextButton <IconTextButton
v-tooltip.top="
hasDisabledUpdatePacks ? $t('manager.disabledNodesWontUpdate') : null
"
v-bind="$attrs" v-bind="$attrs"
type="transparent" type="transparent"
:label="$t('manager.updateAll')" :label="$t('manager.updateAll')"
@@ -24,8 +27,9 @@ import type { components } from '@/types/comfyRegistryTypes'
type NodePack = components['schemas']['Node'] type NodePack = components['schemas']['Node']
const { nodePacks } = defineProps<{ const { nodePacks, hasDisabledUpdatePacks } = defineProps<{
nodePacks: NodePack[] nodePacks: NodePack[]
hasDisabledUpdatePacks?: boolean
}>() }>()
const isUpdating = ref<boolean>(false) const isUpdating = ref<boolean>(false)

View File

@@ -13,7 +13,11 @@
:has-conflict="hasConflicts" :has-conflict="hasConflicts"
:conflict-info="conflictInfo" :conflict-info="conflictInfo"
/> />
<PackEnableToggle v-else :node-pack="nodePack" /> <PackEnableToggle
v-else
:has-conflict="hasConflicts"
:node-pack="nodePack"
/>
</div> </div>
</template> </template>

View File

@@ -34,7 +34,8 @@
/> />
<PackUpdateButton <PackUpdateButton
v-if="isUpdateAvailableTab && hasUpdateAvailable" v-if="isUpdateAvailableTab && hasUpdateAvailable"
:node-packs="updateAvailableNodePacks" :node-packs="enabledUpdateAvailableNodePacks"
:has-disabled-update-packs="hasDisabledUpdatePacks"
/> />
</div> </div>
<div class="flex mt-3 text-sm"> <div class="flex mt-3 text-sm">
@@ -103,8 +104,11 @@ const { t } = useI18n()
const { missingNodePacks, isLoading, error } = useMissingNodes() const { missingNodePacks, isLoading, error } = useMissingNodes()
// Use the composable to get update available nodes // Use the composable to get update available nodes
const { hasUpdateAvailable, updateAvailableNodePacks } = const {
useUpdateAvailableNodes() hasUpdateAvailable,
enabledUpdateAvailableNodePacks,
hasDisabledUpdatePacks
} = useUpdateAvailableNodes()
const hasResults = computed( const hasResults = computed(
() => searchQuery.value?.trim() && searchResults?.length () => searchQuery.value?.trim() && searchResults?.length

View File

@@ -34,7 +34,6 @@
<script setup lang="ts"> <script setup lang="ts">
import Divider from 'primevue/divider' import Divider from 'primevue/divider'
import Tag from 'primevue/tag' import Tag from 'primevue/tag'
import { onMounted } from 'vue'
import SystemStatsPanel from '@/components/common/SystemStatsPanel.vue' import SystemStatsPanel from '@/components/common/SystemStatsPanel.vue'
import { useAboutPanelStore } from '@/stores/aboutPanelStore' import { useAboutPanelStore } from '@/stores/aboutPanelStore'
@@ -44,10 +43,4 @@ import PanelTemplate from './PanelTemplate.vue'
const systemStatsStore = useSystemStatsStore() const systemStatsStore = useSystemStatsStore()
const aboutPanelStore = useAboutPanelStore() const aboutPanelStore = useAboutPanelStore()
onMounted(async () => {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
})
</script> </script>

View File

@@ -2,12 +2,12 @@
<div <div
ref="toolboxRef" ref="toolboxRef"
style="transform: translate(var(--tb-x), var(--tb-y))" style="transform: translate(var(--tb-x), var(--tb-y))"
class="fixed left-0 top-0 z-40" class="fixed left-0 top-0 z-40 pointer-events-none"
> >
<Transition name="slide-up"> <Transition name="slide-up">
<Panel <Panel
v-if="visible" v-if="visible"
class="rounded-lg selection-toolbox" class="rounded-lg selection-toolbox pointer-events-auto"
:pt="{ :pt="{
header: 'hidden', header: 'hidden',
content: 'p-0 flex flex-row' content: 'p-0 flex flex-row'
@@ -83,7 +83,6 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
<style scoped> <style scoped>
.selection-toolbox { .selection-toolbox {
transform: translateX(-50%) translateY(-120%); transform: translateX(-50%) translateY(-120%);
will-change: transform, opacity;
} }
@keyframes slideUp { @keyframes slideUp {

View File

@@ -147,7 +147,8 @@ watch(
showColorPicker.value = false showColorPicker.value = false
selectedColorOption.value = null selectedColorOption.value = null
currentColorOption.value = getItemsColorOption(newSelectedItems) currentColorOption.value = getItemsColorOption(newSelectedItems)
} },
{ immediate: true }
) )
</script> </script>

View File

@@ -142,11 +142,12 @@ import { useI18n } from 'vue-i18n'
import PuzzleIcon from '@/components/icons/PuzzleIcon.vue' import PuzzleIcon from '@/components/icons/PuzzleIcon.vue'
import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment' import { useConflictAcknowledgment } from '@/composables/useConflictAcknowledgment'
import { useDialogService } from '@/services/dialogService' import { useManagerState } from '@/composables/useManagerState'
import { type ReleaseNote } from '@/services/releaseService' import { type ReleaseNote } from '@/services/releaseService'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useReleaseStore } from '@/stores/releaseStore' import { useReleaseStore } from '@/stores/releaseStore'
import { useSettingStore } from '@/stores/settingStore' import { useSettingStore } from '@/stores/settingStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { electronAPI, isElectron } from '@/utils/envUtil' import { electronAPI, isElectron } from '@/utils/envUtil'
import { formatVersionAnchor } from '@/utils/formatUtil' import { formatVersionAnchor } from '@/utils/formatUtil'
@@ -191,7 +192,6 @@ const { t, locale } = useI18n()
const releaseStore = useReleaseStore() const releaseStore = useReleaseStore()
const commandStore = useCommandStore() const commandStore = useCommandStore()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const dialogService = useDialogService()
// Emits // Emits
const emit = defineEmits<{ const emit = defineEmits<{
@@ -313,8 +313,11 @@ const menuItems = computed<MenuItem[]>(() => {
icon: PuzzleIcon, icon: PuzzleIcon,
label: t('helpCenter.managerExtension'), label: t('helpCenter.managerExtension'),
showRedDot: shouldShowManagerRedDot.value, showRedDot: shouldShowManagerRedDot.value,
action: () => { action: async () => {
dialogService.showManagerDialog() await useManagerState().openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
})
emit('close') emit('close')
} }
}, },

View File

@@ -88,8 +88,8 @@ const { shouldShowRedDot: shouldShowConflictRedDot, markConflictsAsSeen } =
useConflictAcknowledgment() useConflictAcknowledgment()
// Use either release red dot or conflict red dot // Use either release red dot or conflict red dot
const shouldShowRedDot = computed(() => { const shouldShowRedDot = computed((): boolean => {
const releaseRedDot = showReleaseRedDot const releaseRedDot = showReleaseRedDot.value
return releaseRedDot || shouldShowConflictRedDot.value return releaseRedDot || shouldShowConflictRedDot.value
}) })

View File

@@ -106,16 +106,13 @@ import { useI18n } from 'vue-i18n'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue' import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue' import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue' import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { useDialogService } from '@/services/dialogService' import { useManagerState } from '@/composables/useManagerState'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
import {
ManagerUIState,
useManagerStateStore
} from '@/stores/managerStateStore'
import { useMenuItemStore } from '@/stores/menuItemStore' import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSettingStore } from '@/stores/settingStore' import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore' import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { showNativeSystemMenu } from '@/utils/envUtil' import { showNativeSystemMenu } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil' import { normalizeI18nKey } from '@/utils/formatUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil' import { whileMouseDown } from '@/utils/mouseDownUtil'
@@ -127,6 +124,8 @@ const dialogStore = useDialogStore()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { t } = useI18n() const { t } = useI18n()
const managerState = useManagerState()
const menuRef = ref< const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null ({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null) >(null)
@@ -159,29 +158,11 @@ const showSettings = (defaultPanel?: string) => {
}) })
} }
const managerStateStore = useManagerStateStore()
const showManageExtensions = async () => { const showManageExtensions = async () => {
const state = managerStateStore.managerUIState await managerState.openManager({
initialTab: ManagerTab.All,
switch (state) { showToastOnLegacyError: false
case ManagerUIState.DISABLED: })
showSettings('extension')
break
case ManagerUIState.LEGACY_UI:
try {
await commandStore.execute('Comfy.Manager.Menu.ToggleVisibility')
} catch {
// If legacy command doesn't exist, fall back to extensions panel
showSettings('extension')
}
break
case ManagerUIState.NEW_UI:
useDialogService().showManagerDialog()
break
}
} }
const extraMenuItems = computed<MenuItem[]>(() => [ const extraMenuItems = computed<MenuItem[]>(() => [

View File

@@ -1053,7 +1053,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
) as IComboWidget ) as IComboWidget
if (!modelWidget || !generateAudioWidget) { if (!modelWidget || !generateAudioWidget) {
return '$2.00-6.00/Run (varies with model & audio generation)' return '$0.80-3.20/Run (varies with model & audio generation)'
} }
const model = String(modelWidget.value) const model = String(modelWidget.value)
@@ -1061,13 +1061,13 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
String(generateAudioWidget.value).toLowerCase() === 'true' String(generateAudioWidget.value).toLowerCase() === 'true'
if (model.includes('veo-3.0-fast-generate-001')) { if (model.includes('veo-3.0-fast-generate-001')) {
return generateAudio ? '$3.20/Run' : '$2.00/Run' return generateAudio ? '$1.20/Run' : '$0.80/Run'
} else if (model.includes('veo-3.0-generate-001')) { } else if (model.includes('veo-3.0-generate-001')) {
return generateAudio ? '$6.00/Run' : '$4.00/Run' return generateAudio ? '$3.20/Run' : '$1.60/Run'
} }
// Default fallback // Default fallback
return '$2.00-6.00/Run' return '$0.80-3.20/Run'
} }
}, },
LumaImageNode: { LumaImageNode: {
@@ -1502,6 +1502,32 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
return 'Token-based' return 'Token-based'
} }
}, },
ByteDanceSeedreamNode: {
displayPrice: (node: LGraphNode): string => {
const sequentialGenerationWidget = node.widgets?.find(
(w) => w.name === 'sequential_image_generation'
) as IComboWidget
const maxImagesWidget = node.widgets?.find(
(w) => w.name === 'max_images'
) as IComboWidget
if (!sequentialGenerationWidget || !maxImagesWidget)
return '$0.03/Run ($0.03 for one output image)'
if (
String(sequentialGenerationWidget.value).toLowerCase() === 'disabled'
) {
return '$0.03/Run'
}
const maxImages = Number(maxImagesWidget.value)
if (maxImages === 1) {
return '$0.03/Run'
}
const cost = (0.03 * maxImages).toFixed(2)
return `$${cost}/Run ($0.03 for one output image)`
}
},
ByteDanceTextToVideoNode: { ByteDanceTextToVideoNode: {
displayPrice: byteDanceVideoPricingCalculator displayPrice: byteDanceVideoPricingCalculator
}, },
@@ -1604,6 +1630,11 @@ export const useNodePricing = () => {
// ByteDance // ByteDance
ByteDanceImageNode: ['model'], ByteDanceImageNode: ['model'],
ByteDanceImageEditNode: ['model'], ByteDanceImageEditNode: ['model'],
ByteDanceSeedreamNode: [
'model',
'sequential_image_generation',
'max_images'
],
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'], ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'], ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'], ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],

View File

@@ -44,9 +44,24 @@ export const useUpdateAvailableNodes = () => {
return filterOutdatedPacks(installedPacks.value) return filterOutdatedPacks(installedPacks.value)
}) })
// Check if there are any outdated packs // Filter only enabled outdated packs
const enabledUpdateAvailableNodePacks = computed(() => {
return updateAvailableNodePacks.value.filter((pack) =>
comfyManagerStore.isPackEnabled(pack.id)
)
})
// Check if there are any enabled outdated packs
const hasUpdateAvailable = computed(() => { const hasUpdateAvailable = computed(() => {
return updateAvailableNodePacks.value.length > 0 return enabledUpdateAvailableNodePacks.value.length > 0
})
// Check if there are disabled packs with updates
const hasDisabledUpdatePacks = computed(() => {
return (
updateAvailableNodePacks.value.length >
enabledUpdateAvailableNodePacks.value.length
)
}) })
// Automatically fetch installed pack data when composable is used // Automatically fetch installed pack data when composable is used
@@ -58,7 +73,9 @@ export const useUpdateAvailableNodes = () => {
return { return {
updateAvailableNodePacks, updateAvailableNodePacks,
enabledUpdateAvailableNodePacks,
hasUpdateAvailable, hasUpdateAvailable,
hasDisabledUpdatePacks,
isLoading, isLoading,
error error
} }

View File

@@ -61,7 +61,7 @@ export const useWorkflowPacks = (options: UseNodePacksOptions = {}) => {
const nodeDef = nodeDefStore.nodeDefsByName[nodeName] const nodeDef = nodeDefStore.nodeDefsByName[nodeName]
if (nodeDef?.nodeSource.type === 'core') { if (nodeDef?.nodeSource.type === 'core') {
if (!systemStatsStore.systemStats) { if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats() await systemStatsStore.refetchSystemStats()
} }
return { return {
id: CORE_NODES_PACK_NAME, id: CORE_NODES_PACK_NAME,

View File

@@ -1,3 +1,4 @@
import { until } from '@vueuse/core'
import { uniqBy } from 'es-toolkit/compat' import { uniqBy } from 'es-toolkit/compat'
import { computed, getCurrentInstance, onUnmounted, readonly, ref } from 'vue' import { computed, getCurrentInstance, onUnmounted, readonly, ref } from 'vue'
@@ -21,6 +22,7 @@ import type {
NodePackRequirements, NodePackRequirements,
SystemEnvironment SystemEnvironment
} from '@/types/conflictDetectionTypes' } from '@/types/conflictDetectionTypes'
import { normalizePackId } from '@/utils/packUtils'
import { import {
cleanVersion, cleanVersion,
satisfiesVersion, satisfiesVersion,
@@ -78,9 +80,8 @@ export function useConflictDetection() {
try { try {
// Get system stats from store (primary source of system information) // Get system stats from store (primary source of system information)
const systemStatsStore = useSystemStatsStore() const systemStatsStore = useSystemStatsStore()
if (!systemStatsStore.systemStats) { // Wait for systemStats to be initialized if not already
await systemStatsStore.fetchSystemStats() await until(systemStatsStore.isInitialized)
}
// Fetch version information from backend (with error resilience) // Fetch version information from backend (with error resilience)
const [frontendVersion] = await Promise.allSettled([ const [frontendVersion] = await Promise.allSettled([
@@ -127,7 +128,7 @@ export function useConflictDetection() {
} }
systemEnvironment.value = environment systemEnvironment.value = environment
console.log( console.debug(
'[ConflictDetection] System environment detection completed:', '[ConflictDetection] System environment detection completed:',
environment environment
) )
@@ -427,7 +428,7 @@ export function useConflictDetection() {
Object.entries(bulkResult).forEach(([packageId, failInfo]) => { Object.entries(bulkResult).forEach(([packageId, failInfo]) => {
if (failInfo !== null) { if (failInfo !== null) {
importFailures[packageId] = failInfo importFailures[packageId] = failInfo
console.log( console.debug(
`[ConflictDetection] Import failure found for ${packageId}:`, `[ConflictDetection] Import failure found for ${packageId}:`,
failInfo failInfo
) )
@@ -500,7 +501,7 @@ export function useConflictDetection() {
*/ */
async function performConflictDetection(): Promise<ConflictDetectionResponse> { async function performConflictDetection(): Promise<ConflictDetectionResponse> {
if (isDetecting.value) { if (isDetecting.value) {
console.log('[ConflictDetection] Already detecting, skipping') console.debug('[ConflictDetection] Already detecting, skipping')
return { return {
success: false, success: false,
error_message: 'Already detecting conflicts', error_message: 'Already detecting conflicts',
@@ -556,7 +557,10 @@ export function useConflictDetection() {
detectionSummary.value = summary detectionSummary.value = summary
lastDetectionTime.value = new Date().toISOString() lastDetectionTime.value = new Date().toISOString()
console.log('[ConflictDetection] Conflict detection completed:', summary) console.debug(
'[ConflictDetection] Conflict detection completed:',
summary
)
// Store conflict results for later UI display // Store conflict results for later UI display
// Dialog will be shown based on specific events, not on app mount // Dialog will be shown based on specific events, not on app mount
@@ -568,7 +572,7 @@ export function useConflictDetection() {
// Merge conflicts for packages with the same name // Merge conflicts for packages with the same name
const mergedConflicts = mergeConflictsByPackageName(conflictedResults) const mergedConflicts = mergeConflictsByPackageName(conflictedResults)
console.log( console.debug(
'[ConflictDetection] Conflicts detected (stored for UI):', '[ConflictDetection] Conflicts detected (stored for UI):',
mergedConflicts mergedConflicts
) )
@@ -632,11 +636,22 @@ export function useConflictDetection() {
/** /**
* Error-resilient initialization (called on app mount). * Error-resilient initialization (called on app mount).
* Async function that doesn't block UI setup. * Async function that doesn't block UI setup.
* Ensures proper order: installed -> system_stats -> versions bulk -> import_fail_info_bulk * Ensures proper order: system_stats -> manager state -> installed -> versions bulk -> import_fail_info_bulk
*/ */
async function initializeConflictDetection(): Promise<void> { async function initializeConflictDetection(): Promise<void> {
try { try {
// Simply perform conflict detection // Check if manager is new Manager before proceeding
const { useManagerState } = await import('@/composables/useManagerState')
const managerState = useManagerState()
if (!managerState.isNewManagerUI.value) {
console.debug(
'[ConflictDetection] Manager is not new Manager, skipping conflict detection'
)
return
}
// Manager is new Manager, perform conflict detection
// The useInstalledPacks will handle fetching installed list if needed // The useInstalledPacks will handle fetching installed list if needed
await performConflictDetection() await performConflictDetection()
} catch (error) { } catch (error) {
@@ -671,13 +686,13 @@ export function useConflictDetection() {
* Check if conflicts should trigger modal display after "What's New" dismissal * Check if conflicts should trigger modal display after "What's New" dismissal
*/ */
async function shouldShowConflictModalAfterUpdate(): Promise<boolean> { async function shouldShowConflictModalAfterUpdate(): Promise<boolean> {
console.log( console.debug(
'[ConflictDetection] Checking if conflict modal should show after update...' '[ConflictDetection] Checking if conflict modal should show after update...'
) )
// Ensure conflict detection has run // Ensure conflict detection has run
if (detectionResults.value.length === 0) { if (detectionResults.value.length === 0) {
console.log( console.debug(
'[ConflictDetection] No detection results, running conflict detection...' '[ConflictDetection] No detection results, running conflict detection...'
) )
await performConflictDetection() await performConflictDetection()
@@ -689,7 +704,7 @@ export function useConflictDetection() {
const hasActualConflicts = hasConflicts.value const hasActualConflicts = hasConflicts.value
const canShowModal = acknowledgment.shouldShowConflictModal.value const canShowModal = acknowledgment.shouldShowConflictModal.value
console.log('[ConflictDetection] Modal check:', { console.debug('[ConflictDetection] Modal check:', {
hasConflicts: hasActualConflicts, hasConflicts: hasActualConflicts,
canShowModal: canShowModal, canShowModal: canShowModal,
conflictedPackagesCount: conflictedPackages.value.length conflictedPackagesCount: conflictedPackages.value.length
@@ -860,9 +875,7 @@ function mergeConflictsByPackageName(
conflicts.forEach((conflict) => { conflicts.forEach((conflict) => {
// Normalize package name by removing version suffix (@1_0_3) for consistent merging // Normalize package name by removing version suffix (@1_0_3) for consistent merging
const normalizedPackageName = conflict.package_name.includes('@') const normalizedPackageName = normalizePackId(conflict.package_name)
? conflict.package_name.substring(0, conflict.package_name.indexOf('@'))
: conflict.package_name
if (mergedMap.has(normalizedPackageName)) { if (mergedMap.has(normalizedPackageName)) {
// Package already exists, merge conflicts // Package already exists, merge conflicts

View File

@@ -1,5 +1,6 @@
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { ManagerUIState, useManagerState } from '@/composables/useManagerState'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog' import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import { import {
DEFAULT_DARK_COLOR_PALETTE, DEFAULT_DARK_COLOR_PALETTE,
@@ -20,15 +21,10 @@ import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService' import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService' import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore' import type { ComfyCommand } from '@/stores/commandStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore' import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useHelpCenterStore } from '@/stores/helpCenterStore' import { useHelpCenterStore } from '@/stores/helpCenterStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import {
ManagerUIState,
useManagerStateStore
} from '@/stores/managerStateStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore' import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore' import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
@@ -720,34 +716,9 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Custom Nodes Manager', label: 'Custom Nodes Manager',
versionAdded: '1.12.10', versionAdded: '1.12.10',
function: async () => { function: async () => {
const managerState = useManagerStateStore().managerUIState await useManagerState().openManager({
showToastOnLegacyError: true
switch (managerState) { })
case ManagerUIState.DISABLED:
dialogService.showSettingsDialog('extension')
break
case ManagerUIState.LEGACY_UI:
try {
await useCommandStore().execute(
'Comfy.Manager.Menu.ToggleVisibility' // This command is registered by legacy manager FE extension
)
} catch (error) {
console.error('error', error)
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
dialogService.showManagerDialog()
}
break
case ManagerUIState.NEW_UI:
dialogService.showManagerDialog()
break
}
} }
}, },
{ {
@@ -755,33 +726,25 @@ export function useCoreCommands(): ComfyCommand[] {
icon: 'pi pi-sync', icon: 'pi pi-sync',
label: 'Check for Custom Node Updates', label: 'Check for Custom Node Updates',
versionAdded: '1.17.0', versionAdded: '1.17.0',
function: () => { function: async () => {
const managerStore = useManagerStateStore() const managerState = useManagerState()
const state = managerStore.managerUIState const state = managerState.managerUIState.value
switch (state) { // For DISABLED state, show error toast instead of opening settings
case ManagerUIState.DISABLED: if (state === ManagerUIState.DISABLED) {
toastStore.add({ toastStore.add({
severity: 'error', severity: 'error',
summary: t('g.error'), summary: t('g.error'),
detail: t('manager.notAvailable'), detail: t('manager.notAvailable'),
life: 3000 life: 3000
}) })
break return
case ManagerUIState.LEGACY_UI:
useCommandStore()
.execute('Comfy.Manager.Menu.ToggleVisibility')
.catch(() => {
// If legacy command doesn't exist, fall back to extensions panel
dialogService.showSettingsDialog('extension')
})
break
case ManagerUIState.NEW_UI:
dialogService.showManagerDialog()
break
} }
await managerState.openManager({
initialTab: ManagerTab.UpdateAvailable,
showToastOnLegacyError: false
})
} }
}, },
{ {
@@ -790,32 +753,10 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Install Missing Custom Nodes', label: 'Install Missing Custom Nodes',
versionAdded: '1.17.0', versionAdded: '1.17.0',
function: async () => { function: async () => {
const managerStore = useManagerStateStore() await useManagerState().openManager({
const state = managerStore.managerUIState initialTab: ManagerTab.Missing,
showToastOnLegacyError: false
switch (state) { })
case ManagerUIState.DISABLED:
// When manager is disabled, open the extensions panel in settings
dialogService.showSettingsDialog('extension')
break
case ManagerUIState.LEGACY_UI:
try {
await useCommandStore().execute(
'Comfy.Manager.Menu.ToggleVisibility'
)
} catch {
// If legacy command doesn't exist, fall back to extensions panel
dialogService.showSettingsDialog('extension')
}
break
case ManagerUIState.NEW_UI:
dialogService.showManagerDialog({
initialTab: ManagerTab.Missing
})
break
}
} }
}, },
{ {
@@ -921,8 +862,11 @@ export function useCoreCommands(): ComfyCommand[] {
id: 'Comfy.OpenManagerDialog', id: 'Comfy.OpenManagerDialog',
icon: 'mdi mdi-puzzle-outline', icon: 'mdi mdi-puzzle-outline',
label: 'Manager', label: 'Manager',
function: () => { function: async () => {
dialogService.showManagerDialog() await useManagerState().openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
})
} }
}, },
{ {
@@ -987,18 +931,11 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Custom Nodes (Legacy)', label: 'Custom Nodes (Legacy)',
versionAdded: '1.16.4', versionAdded: '1.16.4',
function: async () => { function: async () => {
try { await useManagerState().openManager({
await useCommandStore().execute( legacyCommand: 'Comfy.Manager.CustomNodesManager.ToggleVisibility',
'Comfy.Manager.CustomNodesManager.ToggleVisibility' showToastOnLegacyError: true,
) isLegacyOnly: true
} catch (error) { })
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
}
} }
}, },
{ {
@@ -1007,16 +944,10 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Manager Menu (Legacy)', label: 'Manager Menu (Legacy)',
versionAdded: '1.16.4', versionAdded: '1.16.4',
function: async () => { function: async () => {
try { await useManagerState().openManager({
await useCommandStore().execute('Comfy.Manager.Menu.ToggleVisibility') showToastOnLegacyError: true,
} catch (error) { isLegacyOnly: true
useToastStore().add({ })
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
}
} }
}, },
{ {

View File

@@ -5,6 +5,7 @@ import { Ref, computed, ref } from 'vue'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { components } from '@/types/generatedManagerTypes' import { components } from '@/types/generatedManagerTypes'
import { normalizePackKeys } from '@/utils/packUtils'
type ManagerTaskHistory = Record< type ManagerTaskHistory = Record<
string, string,
@@ -98,7 +99,8 @@ export const useManagerQueue = (
taskHistory.value = filterHistoryByClientId(state.history) taskHistory.value = filterHistoryByClientId(state.history)
if (state.installed_packs) { if (state.installed_packs) {
installedPacks.value = state.installed_packs // Normalize pack keys to ensure consistent access
installedPacks.value = normalizePackKeys(state.installed_packs)
} }
updateProcessingState() updateProcessingState()
} }

View File

@@ -0,0 +1,208 @@
import { storeToRefs } from 'pinia'
import { computed, readonly } from 'vue'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { useToastStore } from '@/stores/toastStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
export enum ManagerUIState {
DISABLED = 'disabled',
LEGACY_UI = 'legacy',
NEW_UI = 'new'
}
export function useManagerState() {
const systemStatsStore = useSystemStatsStore()
const { systemStats, isInitialized: systemInitialized } =
storeToRefs(systemStatsStore)
/**
* The current manager UI state.
* Computed once and cached until dependencies change (which they don't during runtime).
* This follows Vue's conventions and provides better performance through caching.
*/
const managerUIState = readonly(
computed((): ManagerUIState => {
// Wait for systemStats to be initialized
if (!systemInitialized.value) {
// Default to DISABLED while loading
return ManagerUIState.DISABLED
}
// Get current values
const clientSupportsV4 =
api.getClientFeatureFlags().supports_manager_v4_ui ?? false
const serverSupportsV4 = api.getServerFeature(
'extension.manager.supports_v4'
)
// Check command line args first (highest priority)
// --enable-manager flag enables the manager (opposite of old --disable-manager)
const hasEnableManager =
systemStats.value?.system?.argv?.includes('--enable-manager')
// If --enable-manager is NOT present, manager is disabled
if (!hasEnableManager) {
return ManagerUIState.DISABLED
}
if (
systemStats.value?.system?.argv?.includes('--enable-manager-legacy-ui')
) {
return ManagerUIState.LEGACY_UI
}
// Both client and server support v4 = NEW_UI
if (clientSupportsV4 && serverSupportsV4 === true) {
return ManagerUIState.NEW_UI
}
// Server supports v4 but client doesn't = LEGACY_UI
if (serverSupportsV4 === true && !clientSupportsV4) {
return ManagerUIState.LEGACY_UI
}
// Server explicitly doesn't support v4 = LEGACY_UI
if (serverSupportsV4 === false) {
return ManagerUIState.LEGACY_UI
}
// If server feature flags haven't loaded yet, default to NEW_UI
// This is a temporary state - feature flags are exchanged immediately on WebSocket connection
// NEW_UI is the safest default since v2 API is the current standard
// If the server doesn't support v2, API calls will fail with 404 and be handled gracefully
if (serverSupportsV4 === undefined) {
return ManagerUIState.NEW_UI
}
// Should never reach here, but if we do, disable manager
return ManagerUIState.DISABLED
})
)
/**
* Check if manager is enabled (not DISABLED)
*/
const isManagerEnabled = readonly(
computed((): boolean => {
return managerUIState.value !== ManagerUIState.DISABLED
})
)
/**
* Check if manager UI is in NEW_UI mode
*/
const isNewManagerUI = readonly(
computed((): boolean => {
return managerUIState.value === ManagerUIState.NEW_UI
})
)
/**
* Check if manager UI is in LEGACY_UI mode
*/
const isLegacyManagerUI = readonly(
computed((): boolean => {
return managerUIState.value === ManagerUIState.LEGACY_UI
})
)
/**
* Check if install button should be shown (only in NEW_UI mode)
*/
const shouldShowInstallButton = readonly(
computed((): boolean => {
return isNewManagerUI.value
})
)
/**
* Check if manager buttons should be shown (when manager is not disabled)
*/
const shouldShowManagerButtons = readonly(
computed((): boolean => {
return isManagerEnabled.value
})
)
/**
* Opens the manager UI based on current state
* Centralizes the logic for opening manager across the app
* @param options - Optional configuration for opening the manager
* @param options.initialTab - Initial tab to show (for NEW_UI mode)
* @param options.legacyCommand - Legacy command to execute (for LEGACY_UI mode)
* @param options.showToastOnLegacyError - Whether to show toast on legacy command failure
* @param options.isLegacyOnly - If true, shows error in NEW_UI mode instead of opening manager
*/
const openManager = async (options?: {
initialTab?: ManagerTab
legacyCommand?: string
showToastOnLegacyError?: boolean
isLegacyOnly?: boolean
}): Promise<void> => {
const state = managerUIState.value
const dialogService = useDialogService()
const commandStore = useCommandStore()
switch (state) {
case ManagerUIState.DISABLED:
dialogService.showSettingsDialog('extension')
break
case ManagerUIState.LEGACY_UI: {
const command =
options?.legacyCommand || 'Comfy.Manager.Menu.ToggleVisibility'
try {
await commandStore.execute(command)
} catch {
// If legacy command doesn't exist
if (options?.showToastOnLegacyError !== false) {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
}
// Fallback to extensions panel if not showing toast
if (options?.showToastOnLegacyError === false) {
dialogService.showSettingsDialog('extension')
}
}
break
}
case ManagerUIState.NEW_UI:
if (options?.isLegacyOnly) {
// Legacy command is not available in NEW_UI mode
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.legacyMenuNotAvailable'),
life: 3000
})
dialogService.showManagerDialog({ initialTab: ManagerTab.All })
} else {
dialogService.showManagerDialog(
options?.initialTab ? { initialTab: options.initialTab } : undefined
)
}
break
}
}
return {
managerUIState,
isManagerEnabled,
isNewManagerUI,
isLegacyManagerUI,
shouldShowInstallButton,
shouldShowManagerButtons,
openManager
}
}

View File

@@ -187,6 +187,8 @@
"updateSelected": "Update Selected", "updateSelected": "Update Selected",
"updateAll": "Update All", "updateAll": "Update All",
"updatingAllPacks": "Updating all packages", "updatingAllPacks": "Updating all packages",
"disabledNodesWontUpdate": "Disabled nodes will not be updated",
"enablePackToChangeVersion": "Enable this pack to change versions",
"license": "License", "license": "License",
"nightlyVersion": "Nightly", "nightlyVersion": "Nightly",
"latestVersion": "Latest", "latestVersion": "Latest",
@@ -205,6 +207,7 @@
"noDescription": "No description available", "noDescription": "No description available",
"installSelected": "Install Selected", "installSelected": "Install Selected",
"installAllMissingNodes": "Install All Missing Nodes", "installAllMissingNodes": "Install All Missing Nodes",
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
"packsSelected": "packs selected", "packsSelected": "packs selected",
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection", "mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
"notAvailable": "Not Available", "notAvailable": "Not Available",
@@ -1436,6 +1439,8 @@
"missingModelsMessage": "When loading the graph, the following models were not found" "missingModelsMessage": "When loading the graph, the following models were not found"
}, },
"loadWorkflowWarning": { "loadWorkflowWarning": {
"missingNodesTitle": "Some Nodes Are Missing",
"missingNodesDescription": "When loading the graph, the following node types were not found.\nThis may also happen if your installed version is lower and that node type cant be found.",
"outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.", "outdatedVersion": "Some nodes require a newer version of ComfyUI (current: {version}). Please update to use all nodes.",
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.", "outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
"coreNodesFromVersion": "Requires ComfyUI {version}:" "coreNodesFromVersion": "Requires ComfyUI {version}:"

View File

@@ -2,6 +2,7 @@ import axios, { AxiosError, AxiosResponse } from 'axios'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { ref } from 'vue' import { ref } from 'vue'
import { useManagerState } from '@/composables/useManagerState'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { components } from '@/types/generatedManagerTypes' import { components } from '@/types/generatedManagerTypes'
import { isAbortError } from '@/utils/typeGuardUtil' import { isAbortError } from '@/utils/typeGuardUtil'
@@ -44,11 +45,18 @@ const managerApiClient = axios.create({
/** /**
* Service for interacting with the ComfyUI Manager API * Service for interacting with the ComfyUI Manager API
* Provides methods for managing packs, ComfyUI-Manager queue operations, and system functions * Provides methods for managing packs, ComfyUI-Manager queue operations, and system functions
* Note: This service should only be used when Manager state is NEW_UI
*/ */
export const useComfyManagerService = () => { export const useComfyManagerService = () => {
const isLoading = ref(false) const isLoading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
// Check if manager service should be available
const isManagerServiceAvailable = () => {
const managerState = useManagerState()
return managerState.isNewManagerUI.value
}
const handleRequestError = ( const handleRequestError = (
err: unknown, err: unknown,
context: string, context: string,
@@ -87,6 +95,12 @@ export const useComfyManagerService = () => {
): Promise<T | null> => { ): Promise<T | null> => {
const { errorContext, routeSpecificErrors, isQueueOperation } = options const { errorContext, routeSpecificErrors, isQueueOperation } = options
// Block service calls if not in NEW_UI state
if (!isManagerServiceAvailable()) {
error.value = 'Manager service is not available in current mode'
return null
}
isLoading.value = true isLoading.value = true
error.value = null error.value = null
@@ -151,6 +165,10 @@ export const useComfyManagerService = () => {
) => { ) => {
const errorContext = 'Fetching bulk import failure information' const errorContext = 'Fetching bulk import failure information'
if (!params.cnr_ids?.length && !params.urls?.length) {
return {}
}
return executeRequest<components['schemas']['ImportFailInfoBulkResponse']>( return executeRequest<components['schemas']['ImportFailInfoBulkResponse']>(
() => () =>
managerApiClient.post(ManagerRoute.IMPORT_FAIL_INFO_BULK, params, { managerApiClient.post(ManagerRoute.IMPORT_FAIL_INFO_BULK, params, {

View File

@@ -1,5 +1,4 @@
import { useEventListener, whenever } from '@vueuse/core' import { useEventListener, whenever } from '@vueuse/core'
import { mapKeys } from 'es-toolkit/compat'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
@@ -14,6 +13,7 @@ import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { TaskLog } from '@/types/comfyManagerTypes' import { TaskLog } from '@/types/comfyManagerTypes'
import { components } from '@/types/generatedManagerTypes' import { components } from '@/types/generatedManagerTypes'
import { normalizePackKeys } from '@/utils/packUtils'
type InstallPackParams = components['schemas']['InstallPackParams'] type InstallPackParams = components['schemas']['InstallPackParams']
type InstalledPacksResponse = components['schemas']['InstalledPacksResponse'] type InstalledPacksResponse = components['schemas']['InstalledPacksResponse']
@@ -185,12 +185,8 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
const refreshInstalledList = async () => { const refreshInstalledList = async () => {
const packs = await managerService.listInstalledPacks() const packs = await managerService.listInstalledPacks()
if (packs) { if (packs) {
// The keys are 'cleaned' by stripping the version suffix. // Normalize pack keys to ensure consistent access
// The pack object itself (the value) still contains the version info. installedPacks.value = normalizePackKeys(packs)
const packsWithCleanedKeys = mapKeys(packs, (_value, key) => {
return key.split('@')[0]
})
installedPacks.value = packsWithCleanedKeys
} }
isStale.value = false isStale.value = false
} }

View File

@@ -1,76 +0,0 @@
import { defineStore } from 'pinia'
import { computed, readonly } from 'vue'
import { api } from '@/scripts/api'
import { useExtensionStore } from '@/stores/extensionStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
export enum ManagerUIState {
DISABLED = 'disabled',
LEGACY_UI = 'legacy',
NEW_UI = 'new'
}
export const useManagerStateStore = defineStore('managerState', () => {
const systemStatsStore = useSystemStatsStore()
const extensionStore = useExtensionStore()
// Reactive computed manager state that updates when dependencies change
const managerUIState = computed(() => {
const systemStats = systemStatsStore.systemStats
const clientSupportsV4 =
api.getClientFeatureFlags().supports_manager_v4_ui ?? false
const hasLegacyManager = extensionStore.extensions.some(
(ext) => ext.name === 'Comfy.CustomNodesManager'
)
const serverSupportsV4 = api.getServerFeature(
'extension.manager.supports_v4'
)
console.log('[Manager State Debug]', {
systemStats: systemStats?.system?.argv,
clientSupportsV4,
serverSupportsV4,
hasLegacyManager,
extensions: extensionStore.extensions.map((e) => e.name)
})
// Check command line args first
if (systemStats?.system?.argv?.includes('--disable-manager')) {
return ManagerUIState.DISABLED // comfyui_manager package not installed
}
if (systemStats?.system?.argv?.includes('--enable-manager-legacy-ui')) {
return ManagerUIState.LEGACY_UI // forced legacy
}
// Both client and server support v4 = NEW_UI
if (clientSupportsV4 && serverSupportsV4 === true) {
return ManagerUIState.NEW_UI
}
// Server supports v4 but client doesn't = LEGACY_UI
if (serverSupportsV4 === true) {
return ManagerUIState.LEGACY_UI
}
// No server v4 support but legacy manager extension exists = LEGACY_UI
if (hasLegacyManager) {
return ManagerUIState.LEGACY_UI
}
// If server feature flags haven't loaded yet, return DISABLED for now
// This will update reactively once feature flags load
if (serverSupportsV4 === undefined) {
return ManagerUIState.DISABLED
}
// No manager at all = DISABLED
return ManagerUIState.DISABLED
})
return {
managerUIState: readonly(managerUIState)
}
})

View File

@@ -1,3 +1,4 @@
import { until } from '@vueuse/core'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@@ -240,7 +241,7 @@ export const useReleaseStore = defineStore('release', () => {
try { try {
// Ensure system stats are loaded // Ensure system stats are loaded
if (!systemStatsStore.systemStats) { if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats() await until(systemStatsStore.isInitialized)
} }
const fetchedReleases = await releaseService.getReleases({ const fetchedReleases = await releaseService.getReleases({

View File

@@ -1,32 +1,34 @@
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { SystemStats } from '@/schemas/apiSchema' import type { SystemStats } from '@/schemas/apiSchema'
import { api } from '@/scripts/api' import { api } from '@/scripts/api'
import { isElectron } from '@/utils/envUtil' import { isElectron } from '@/utils/envUtil'
export const useSystemStatsStore = defineStore('systemStats', () => { export const useSystemStatsStore = defineStore('systemStats', () => {
const systemStats = ref<SystemStats | null>(null) const fetchSystemStatsData = async () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
async function fetchSystemStats() {
isLoading.value = true
error.value = null
try { try {
systemStats.value = await api.getSystemStats() return await api.getSystemStats()
} catch (err) { } catch (err) {
error.value =
err instanceof Error
? err.message
: 'An error occurred while fetching system stats'
console.error('Error fetching system stats:', err) console.error('Error fetching system stats:', err)
} finally { throw err
isLoading.value = false
} }
} }
const {
state: systemStats,
isLoading,
error,
isReady: isInitialized,
execute: refetchSystemStats
} = useAsyncState<SystemStats | null>(
fetchSystemStatsData,
null, // initial value
{
immediate: true
}
)
function getFormFactor(): string { function getFormFactor(): string {
if (!systemStats.value?.system?.os) { if (!systemStats.value?.system?.os) {
return 'other' return 'other'
@@ -62,7 +64,8 @@ export const useSystemStatsStore = defineStore('systemStats', () => {
systemStats, systemStats,
isLoading, isLoading,
error, error,
fetchSystemStats, isInitialized,
refetchSystemStats,
getFormFactor getFormFactor
} }
}) })

View File

@@ -1,4 +1,4 @@
import { useStorage } from '@vueuse/core' import { until, useStorage } from '@vueuse/core'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import * as semver from 'semver' import * as semver from 'semver'
import { computed } from 'vue' import { computed } from 'vue'
@@ -103,7 +103,7 @@ export const useVersionCompatibilityStore = defineStore(
async function checkVersionCompatibility() { async function checkVersionCompatibility() {
if (!systemStatsStore.systemStats) { if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats() await until(systemStatsStore.isInitialized)
} }
} }

View File

@@ -1,12 +1,14 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource' import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import { normalizePackId } from '@/utils/packUtils'
export function extractCustomNodeName( export function extractCustomNodeName(
pythonModule: string | undefined pythonModule: string | undefined
): string | null { ): string | null {
const modules = pythonModule?.split('.') || [] const modules = pythonModule?.split('.') || []
if (modules.length >= 2 && modules[0] === 'custom_nodes') { if (modules.length >= 2 && modules[0] === 'custom_nodes') {
return modules[1].split('@')[0] // Use normalizePackId to remove version suffix
return normalizePackId(modules[1])
} }
return null return null
} }

35
src/utils/packUtils.ts Normal file
View File

@@ -0,0 +1,35 @@
import { mapKeys } from 'es-toolkit/compat'
/**
* Normalizes a pack ID by removing the version suffix.
*
* ComfyUI-Manager returns pack IDs in different formats:
* - Enabled packs: "packname" (without version)
* - Disabled packs: "packname@1_0_3" (with version suffix)
* - Latest versions from registry: "packname" (without version)
*
* Since the pack object itself contains the version info (ver field),
* we normalize all pack IDs to just the base name for consistent access.
* This ensures we can always find a pack by its base name (nodePack.id)
* regardless of its enabled/disabled state.
*
* @param packId - The pack ID that may contain a version suffix
* @returns The normalized pack ID without version suffix
*/
export function normalizePackId(packId: string): string {
return packId.split('@')[0]
}
/**
* Normalizes all keys in a pack record by removing version suffixes.
* This is used when receiving pack data from the server to ensure
* consistent key format across the application.
*
* @param packs - Record of packs with potentially versioned keys
* @returns Record with normalized keys
*/
export function normalizePackKeys<T>(
packs: Record<string, T>
): Record<string, T> {
return mapKeys(packs, (_value, key) => normalizePackId(key))
}

View File

@@ -33,14 +33,14 @@ const createMockNode = (type: string, version?: string): LGraphNode =>
describe('MissingCoreNodesMessage', () => { describe('MissingCoreNodesMessage', () => {
const mockSystemStatsStore = { const mockSystemStatsStore = {
systemStats: null as { system?: { comfyui_version?: string } } | null, systemStats: null as { system?: { comfyui_version?: string } } | null,
fetchSystemStats: vi.fn() refetchSystemStats: vi.fn()
} }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// Reset the mock store state // Reset the mock store state
mockSystemStatsStore.systemStats = null mockSystemStatsStore.systemStats = null
mockSystemStatsStore.fetchSystemStats = vi.fn() mockSystemStatsStore.refetchSystemStats = vi.fn()
// @ts-expect-error - Mocking the return value of useSystemStatsStore for testing. // @ts-expect-error - Mocking the return value of useSystemStatsStore for testing.
// The actual store has more properties, but we only need these for our tests. // The actual store has more properties, but we only need these for our tests.
useSystemStatsStore.mockReturnValue(mockSystemStatsStore) useSystemStatsStore.mockReturnValue(mockSystemStatsStore)
@@ -86,15 +86,11 @@ describe('MissingCoreNodesMessage', () => {
expect(wrapper.findComponent(Message).exists()).toBe(true) expect(wrapper.findComponent(Message).exists()).toBe(true)
}) })
it('fetches and displays current ComfyUI version', async () => { it('displays current ComfyUI version when available', async () => {
// Start with no systemStats to trigger fetch // Set systemStats directly (store auto-fetches with useAsyncState)
mockSystemStatsStore.fetchSystemStats.mockImplementation(() => { mockSystemStatsStore.systemStats = {
// Simulate the fetch setting the systemStats system: { comfyui_version: '1.0.0' }
mockSystemStatsStore.systemStats = { }
system: { comfyui_version: '1.0.0' }
}
return Promise.resolve()
})
const missingCoreNodes = { const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')] '1.2.0': [createMockNode('TestNode', '1.2.0')]
@@ -102,20 +98,18 @@ describe('MissingCoreNodesMessage', () => {
const wrapper = mountComponent({ missingCoreNodes }) const wrapper = mountComponent({ missingCoreNodes })
// Wait for all async operations // Wait for component to render
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick() await nextTick()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled() // No need to check if fetchSystemStats was called since useAsyncState auto-fetches
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain(
'Some nodes require a newer version of ComfyUI (current: 1.0.0)' 'Some nodes require a newer version of ComfyUI (current: 1.0.0)'
) )
}) })
it('displays generic message when version is unavailable', async () => { it('displays generic message when version is unavailable', async () => {
// Mock fetchSystemStats to resolve without setting systemStats // No systemStats set - version unavailable
mockSystemStatsStore.fetchSystemStats.mockResolvedValue(undefined) mockSystemStatsStore.systemStats = null
const missingCoreNodes = { const missingCoreNodes = {
'1.2.0': [createMockNode('TestNode', '1.2.0')] '1.2.0': [createMockNode('TestNode', '1.2.0')]

View File

@@ -505,7 +505,7 @@ describe('useNodePricing', () => {
}) })
describe('dynamic pricing - Veo3VideoGenerationNode', () => { describe('dynamic pricing - Veo3VideoGenerationNode', () => {
it('should return $2.00 for veo-3.0-fast-generate-001 without audio', () => { it('should return $0.80 for veo-3.0-fast-generate-001 without audio', () => {
const { getNodeDisplayPrice } = useNodePricing() const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [ const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-fast-generate-001' }, { name: 'model', value: 'veo-3.0-fast-generate-001' },
@@ -513,49 +513,49 @@ describe('useNodePricing', () => {
]) ])
const price = getNodeDisplayPrice(node) const price = getNodeDisplayPrice(node)
expect(price).toBe('$2.00/Run') expect(price).toBe('$0.80/Run')
}) })
it('should return $3.20 for veo-3.0-fast-generate-001 with audio', () => { it('should return $1.20 for veo-3.0-fast-generate-001 with audio', () => {
const { getNodeDisplayPrice } = useNodePricing() const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [ const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-fast-generate-001' }, { name: 'model', value: 'veo-3.0-fast-generate-001' },
{ name: 'generate_audio', value: true } { name: 'generate_audio', value: true }
]) ])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.20/Run')
})
it('should return $1.60 for veo-3.0-generate-001 without audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-generate-001' },
{ name: 'generate_audio', value: false }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.60/Run')
})
it('should return $3.20 for veo-3.0-generate-001 with audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-generate-001' },
{ name: 'generate_audio', value: true }
])
const price = getNodeDisplayPrice(node) const price = getNodeDisplayPrice(node)
expect(price).toBe('$3.20/Run') expect(price).toBe('$3.20/Run')
}) })
it('should return $4.00 for veo-3.0-generate-001 without audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-generate-001' },
{ name: 'generate_audio', value: false }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$4.00/Run')
})
it('should return $6.00 for veo-3.0-generate-001 with audio', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-generate-001' },
{ name: 'generate_audio', value: true }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$6.00/Run')
})
it('should return range when widgets are missing', () => { it('should return range when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing() const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', []) const node = createMockNode('Veo3VideoGenerationNode', [])
const price = getNodeDisplayPrice(node) const price = getNodeDisplayPrice(node)
expect(price).toBe( expect(price).toBe(
'$2.00-6.00/Run (varies with model & audio generation)' '$0.80-3.20/Run (varies with model & audio generation)'
) )
}) })
@@ -567,7 +567,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node) const price = getNodeDisplayPrice(node)
expect(price).toBe( expect(price).toBe(
'$2.00-6.00/Run (varies with model & audio generation)' '$0.80-3.20/Run (varies with model & audio generation)'
) )
}) })
@@ -579,7 +579,7 @@ describe('useNodePricing', () => {
const price = getNodeDisplayPrice(node) const price = getNodeDisplayPrice(node)
expect(price).toBe( expect(price).toBe(
'$2.00-6.00/Run (varies with model & audio generation)' '$0.80-3.20/Run (varies with model & audio generation)'
) )
}) })
}) })
@@ -1781,6 +1781,38 @@ describe('useNodePricing', () => {
}) })
}) })
describe('dynamic pricing - ByteDanceSeedreamNode', () => {
it('should return fallback when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceSeedreamNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03/Run ($0.03 for one output image)')
})
it('should return $0.03/Run when sequential generation is disabled', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceSeedreamNode', [
{ name: 'sequential_image_generation', value: 'disabled' },
{ name: 'max_images', value: 5 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03/Run')
})
it('should multiply by max_images when sequential generation is enabled', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('ByteDanceSeedreamNode', [
{ name: 'sequential_image_generation', value: 'enabled' },
{ name: 'max_images', value: 4 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.12/Run ($0.03 for one output image)')
})
})
describe('dynamic pricing - ByteDance Seedance video nodes', () => { describe('dynamic pricing - ByteDance Seedance video nodes', () => {
it('should return base 10s range for PRO 1080p on ByteDanceTextToVideoNode', () => { it('should return base 10s range for PRO 1080p on ByteDanceTextToVideoNode', () => {
const { getNodeDisplayPrice } = useNodePricing() const { getNodeDisplayPrice } = useNodePricing()

View File

@@ -96,7 +96,7 @@ describe.skip('useConflictDetection with Registry Store', () => {
} }
const mockSystemStatsStore = { const mockSystemStatsStore = {
fetchSystemStats: vi.fn(), refetchSystemStats: vi.fn(),
systemStats: { systemStats: {
system: { system: {
comfyui_version: '0.3.41', comfyui_version: '0.3.41',
@@ -133,7 +133,7 @@ describe.skip('useConflictDetection with Registry Store', () => {
} as any } as any
// Reset mock functions // Reset mock functions
mockSystemStatsStore.fetchSystemStats.mockResolvedValue(undefined) mockSystemStatsStore.refetchSystemStats.mockResolvedValue(undefined)
mockComfyManagerService.listInstalledPacks.mockReset() mockComfyManagerService.listInstalledPacks.mockReset()
mockComfyManagerService.getImportFailInfo.mockReset() mockComfyManagerService.getImportFailInfo.mockReset()
mockRegistryService.getPackByVersion.mockReset() mockRegistryService.getPackByVersion.mockReset()
@@ -185,7 +185,7 @@ describe.skip('useConflictDetection with Registry Store', () => {
it('should return fallback environment information when systemStatsStore fails', async () => { it('should return fallback environment information when systemStatsStore fails', async () => {
// Mock systemStatsStore failure // Mock systemStatsStore failure
mockSystemStatsStore.fetchSystemStats.mockRejectedValue( mockSystemStatsStore.refetchSystemStats.mockRejectedValue(
new Error('Store failure') new Error('Store failure')
) )
mockSystemStatsStore.systemStats = null mockSystemStatsStore.systemStats = null
@@ -754,7 +754,7 @@ describe.skip('useConflictDetection with Registry Store', () => {
describe('error resilience with Registry Store', () => { describe('error resilience with Registry Store', () => {
it('should continue execution even when system environment detection fails', async () => { it('should continue execution even when system environment detection fails', async () => {
// Mock system stats store failure // Mock system stats store failure
mockSystemStatsStore.fetchSystemStats.mockRejectedValue( mockSystemStatsStore.refetchSystemStats.mockRejectedValue(
new Error('Store error') new Error('Store error')
) )
mockSystemStatsStore.systemStats = null mockSystemStatsStore.systemStats = null
@@ -851,7 +851,7 @@ describe.skip('useConflictDetection with Registry Store', () => {
it('should handle complete system failure gracefully', async () => { it('should handle complete system failure gracefully', async () => {
// Mock all stores/services failing // Mock all stores/services failing
mockSystemStatsStore.fetchSystemStats.mockRejectedValue( mockSystemStatsStore.refetchSystemStats.mockRejectedValue(
new Error('Critical error') new Error('Critical error')
) )
mockSystemStatsStore.systemStats = null mockSystemStatsStore.systemStats = null

View File

@@ -0,0 +1,353 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { ManagerUIState, useManagerState } from '@/composables/useManagerState'
import { api } from '@/scripts/api'
import { useExtensionStore } from '@/stores/extensionStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
// Mock dependencies
vi.mock('@/scripts/api', () => ({
api: {
getClientFeatureFlags: vi.fn(),
getServerFeature: vi.fn()
}
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
}))
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: vi.fn()
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showManagerPopup: vi.fn(),
showLegacyManagerPopup: vi.fn(),
showSettingsDialog: vi.fn()
}))
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn(() => ({
execute: vi.fn()
}))
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: vi.fn(() => ({
add: vi.fn()
}))
}))
describe('useManagerState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('managerUIState property', () => {
it('should return DISABLED state when --enable-manager is NOT present', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py'] } // No --enable-manager flag
}),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
})
it('should return LEGACY_UI state when --enable-manager-legacy-ui is present', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: {
argv: [
'python',
'main.py',
'--enable-manager',
'--enable-manager-legacy-ui'
]
} // Both flags needed
}),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return NEW_UI state when client and server both support v4', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI)
})
it('should return LEGACY_UI state when server supports v4 but client does not', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: false
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return LEGACY_UI state when legacy manager extension exists', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: [{ name: 'Comfy.CustomNodesManager' }]
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return NEW_UI state when server feature flags are undefined', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(undefined)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: undefined },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI)
})
it('should return LEGACY_UI state when server does not support v4', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(false)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should handle null systemStats gracefully', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref(null),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
// When systemStats is null, we can't check for --enable-manager flag, so manager is disabled
expect(managerState.managerUIState.value).toBe(ManagerUIState.DISABLED)
})
})
describe('helper properties', () => {
it('isManagerEnabled should return true when state is not DISABLED', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.isManagerEnabled.value).toBe(true)
})
it('isManagerEnabled should return false when state is DISABLED', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py'] } // No --enable-manager flag means disabled
}),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.isManagerEnabled.value).toBe(false)
})
it('isNewManagerUI should return true when state is NEW_UI', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.isNewManagerUI.value).toBe(true)
})
it('isLegacyManagerUI should return true when state is LEGACY_UI', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: {
argv: [
'python',
'main.py',
'--enable-manager',
'--enable-manager-legacy-ui'
]
} // Both flags needed
}),
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.isLegacyManagerUI.value).toBe(true)
})
it('shouldShowInstallButton should return true only for NEW_UI', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.shouldShowInstallButton.value).toBe(true)
})
it('shouldShowManagerButtons should return true when not DISABLED', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: ref({
system: { argv: ['python', 'main.py', '--enable-manager'] }
}), // Need --enable-manager
isInitialized: ref(true)
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const managerState = useManagerState()
expect(managerState.shouldShowManagerButtons.value).toBe(true)
})
})
})

View File

@@ -63,12 +63,14 @@ describe('useUpdateAvailableNodes', () => {
const mockStartFetchInstalled = vi.fn() const mockStartFetchInstalled = vi.fn()
const mockIsPackInstalled = vi.fn() const mockIsPackInstalled = vi.fn()
const mockGetInstalledPackVersion = vi.fn() const mockGetInstalledPackVersion = vi.fn()
const mockIsPackEnabled = vi.fn()
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// Default setup // Default setup
mockIsPackInstalled.mockReturnValue(true) mockIsPackInstalled.mockReturnValue(true)
mockIsPackEnabled.mockReturnValue(true) // Default: all packs are enabled
mockGetInstalledPackVersion.mockImplementation((id: string) => { mockGetInstalledPackVersion.mockImplementation((id: string) => {
switch (id) { switch (id) {
case 'pack-1': case 'pack-1':
@@ -100,7 +102,8 @@ describe('useUpdateAvailableNodes', () => {
mockUseComfyManagerStore.mockReturnValue({ mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled, isPackInstalled: mockIsPackInstalled,
getInstalledPackVersion: mockGetInstalledPackVersion getInstalledPackVersion: mockGetInstalledPackVersion,
isPackEnabled: mockIsPackEnabled
} as any) } as any)
mockUseInstalledPacks.mockReturnValue({ mockUseInstalledPacks.mockReturnValue({
@@ -357,4 +360,127 @@ describe('useUpdateAvailableNodes', () => {
expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-4') expect(mockIsPackInstalled).toHaveBeenCalledWith('pack-4')
}) })
}) })
describe('enabledUpdateAvailableNodePacks', () => {
it('returns only enabled packs with updates', () => {
mockIsPackEnabled.mockImplementation((id: string) => {
// pack-1 is disabled
return id !== 'pack-1'
})
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0], mockInstalledPacks[1]]),
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks, enabledUpdateAvailableNodePacks } =
useUpdateAvailableNodes()
// pack-1 has updates but is disabled
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(updateAvailableNodePacks.value[0].id).toBe('pack-1')
// enabledUpdateAvailableNodePacks should be empty
expect(enabledUpdateAvailableNodePacks.value).toHaveLength(0)
})
it('returns all packs when all are enabled', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { updateAvailableNodePacks, enabledUpdateAvailableNodePacks } =
useUpdateAvailableNodes()
expect(updateAvailableNodePacks.value).toHaveLength(1)
expect(enabledUpdateAvailableNodePacks.value).toHaveLength(1)
expect(enabledUpdateAvailableNodePacks.value[0].id).toBe('pack-1')
})
})
describe('hasDisabledUpdatePacks', () => {
it('returns true when there are disabled packs with updates', () => {
mockIsPackEnabled.mockImplementation((id: string) => {
// pack-1 is disabled
return id !== 'pack-1'
})
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
expect(hasDisabledUpdatePacks.value).toBe(true)
})
it('returns false when all packs with updates are enabled', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[0]]), // pack-1: outdated
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
expect(hasDisabledUpdatePacks.value).toBe(false)
})
it('returns false when no packs have updates', () => {
mockUseInstalledPacks.mockReturnValue({
installedPacks: ref([mockInstalledPacks[1]]), // pack-2: up to date
isLoading: ref(false),
error: ref(null),
startFetchInstalled: mockStartFetchInstalled
} as any)
const { hasDisabledUpdatePacks } = useUpdateAvailableNodes()
expect(hasDisabledUpdatePacks.value).toBe(false)
})
})
describe('hasUpdateAvailable with disabled packs', () => {
it('returns false when only disabled packs have updates', () => {
mockIsPackEnabled.mockReturnValue(false) // All packs disabled
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(false)
})
it('returns true when at least one enabled pack has updates', () => {
mockIsPackEnabled.mockImplementation((id: string) => {
// Only pack-1 is enabled
return id === 'pack-1'
})
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)
})
})
}) })

View File

@@ -161,5 +161,62 @@ describe('useManagerQueue', () => {
expect(taskHistory.value).toHaveProperty('task1') expect(taskHistory.value).toHaveProperty('task1')
expect(taskHistory.value).not.toHaveProperty('task2') expect(taskHistory.value).not.toHaveProperty('task2')
}) })
it('normalizes pack IDs when updating installed packs', () => {
const queue = createManagerQueue()
const mockState = {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: {
'ComfyUI-GGUF@1_1_4': {
enabled: false,
cnr_id: 'ComfyUI-GGUF',
ver: '1.1.4'
},
'test-pack': {
enabled: true,
cnr_id: 'test-pack',
ver: '2.0.0'
}
}
}
queue.updateTaskState(mockState)
// Packs should be accessible by normalized keys
expect(installedPacks.value['ComfyUI-GGUF']).toEqual({
enabled: false,
cnr_id: 'ComfyUI-GGUF',
ver: '1.1.4'
})
expect(installedPacks.value['test-pack']).toEqual({
enabled: true,
cnr_id: 'test-pack',
ver: '2.0.0'
})
// Version suffixed keys should not exist after normalization
// The pack should be accessible by its base name only (without @version)
expect(installedPacks.value['ComfyUI-GGUF@1_1_4']).toBeUndefined()
})
it('handles empty installed_packs gracefully', () => {
const queue = createManagerQueue()
const mockState: any = {
history: {},
running_queue: [],
pending_queue: [],
installed_packs: undefined
}
// Just call the function - if it throws, the test will fail automatically
queue.updateTaskState(mockState)
// installedPacks should remain unchanged
expect(installedPacks.value).toEqual({})
})
}) })
}) })

View File

@@ -439,4 +439,97 @@ describe('useComfyManagerStore', () => {
expect(store.isPackInstalling('pack-3')).toBe(false) expect(store.isPackInstalling('pack-3')).toBe(false)
}) })
}) })
describe('refreshInstalledList with pack ID normalization', () => {
it('normalizes pack IDs by removing version suffixes', async () => {
const mockPacks = {
'ComfyUI-GGUF@1_1_4': {
enabled: false,
cnr_id: 'ComfyUI-GGUF',
ver: '1.1.4',
aux_id: undefined
},
'ComfyUI-Manager': {
enabled: true,
cnr_id: 'ComfyUI-Manager',
ver: '2.0.0',
aux_id: undefined
}
}
vi.mocked(mockManagerService.listInstalledPacks).mockResolvedValue(
mockPacks
)
const store = useComfyManagerStore()
await store.refreshInstalledList()
// Both packs should be accessible by their base name
expect(store.installedPacks['ComfyUI-GGUF']).toEqual({
enabled: false,
cnr_id: 'ComfyUI-GGUF',
ver: '1.1.4',
aux_id: undefined
})
expect(store.installedPacks['ComfyUI-Manager']).toEqual({
enabled: true,
cnr_id: 'ComfyUI-Manager',
ver: '2.0.0',
aux_id: undefined
})
// Version suffixed keys should not exist
expect(store.installedPacks['ComfyUI-GGUF@1_1_4']).toBeUndefined()
})
it('handles duplicate keys after normalization', async () => {
const mockPacks = {
'test-pack': {
enabled: true,
cnr_id: 'test-pack',
ver: '1.0.0',
aux_id: undefined
},
'test-pack@1_1_0': {
enabled: false,
cnr_id: 'test-pack',
ver: '1.1.0',
aux_id: undefined
}
}
vi.mocked(mockManagerService.listInstalledPacks).mockResolvedValue(
mockPacks
)
const store = useComfyManagerStore()
await store.refreshInstalledList()
// The normalized key should exist (last one wins with mapKeys)
expect(store.installedPacks['test-pack']).toBeDefined()
expect(store.installedPacks['test-pack'].ver).toBe('1.1.0')
})
it('preserves version information for disabled packs', async () => {
const mockPacks = {
'disabled-pack@2_0_0': {
enabled: false,
cnr_id: 'disabled-pack',
ver: '2.0.0',
aux_id: undefined
}
}
vi.mocked(mockManagerService.listInstalledPacks).mockResolvedValue(
mockPacks
)
const store = useComfyManagerStore()
await store.refreshInstalledList()
// Pack should be accessible by base name with version preserved
expect(store.getInstalledPackVersion('disabled-pack')).toBe('2.0.0')
expect(store.isPackInstalled('disabled-pack')).toBe(true)
})
})
}) })

View File

@@ -9,6 +9,10 @@ vi.mock('@/utils/envUtil')
vi.mock('@/services/releaseService') vi.mock('@/services/releaseService')
vi.mock('@/stores/settingStore') vi.mock('@/stores/settingStore')
vi.mock('@/stores/systemStatsStore') vi.mock('@/stores/systemStatsStore')
vi.mock('@vueuse/core', () => ({
until: vi.fn(() => Promise.resolve()),
useStorage: vi.fn(() => ({ value: {} }))
}))
describe('useReleaseStore', () => { describe('useReleaseStore', () => {
let store: ReturnType<typeof useReleaseStore> let store: ReturnType<typeof useReleaseStore>
@@ -49,7 +53,8 @@ describe('useReleaseStore', () => {
comfyui_version: '1.0.0' comfyui_version: '1.0.0'
} }
}, },
fetchSystemStats: vi.fn(), isInitialized: true,
refetchSystemStats: vi.fn(),
getFormFactor: vi.fn(() => 'git-windows') getFormFactor: vi.fn(() => 'git-windows')
} }
@@ -334,12 +339,15 @@ describe('useReleaseStore', () => {
}) })
it('should fetch system stats if not available', async () => { it('should fetch system stats if not available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
mockReleaseService.getReleases.mockResolvedValue([mockRelease]) mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize() await store.initialize()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled() expect(until).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
}) })
it('should not set loading state when notifications disabled', async () => { it('should not set loading state when notifications disabled', async () => {
@@ -401,12 +409,14 @@ describe('useReleaseStore', () => {
}) })
it('should proceed with fetchReleases when system stats are not available', async () => { it('should proceed with fetchReleases when system stats are not available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
mockReleaseService.getReleases.mockResolvedValue([mockRelease]) mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.fetchReleases() await store.fetchReleases()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled() expect(until).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled() expect(mockReleaseService.getReleases).toHaveBeenCalled()
}) })
}) })
@@ -530,7 +540,7 @@ describe('useReleaseStore', () => {
await store.initialize() await store.initialize()
// Should not fetch system stats when notifications disabled // Should not fetch system stats when notifications disabled
expect(mockSystemStatsStore.fetchSystemStats).not.toHaveBeenCalled() expect(mockSystemStatsStore.refetchSystemStats).not.toHaveBeenCalled()
}) })
it('should handle concurrent fetchReleases calls', async () => { it('should handle concurrent fetchReleases calls', async () => {

View File

@@ -21,18 +21,25 @@ describe('useSystemStatsStore', () => {
let store: ReturnType<typeof useSystemStatsStore> let store: ReturnType<typeof useSystemStatsStore>
beforeEach(() => { beforeEach(() => {
// Mock API to prevent automatic fetch on store creation
vi.mocked(api.getSystemStats).mockResolvedValue(null as any)
setActivePinia(createPinia()) setActivePinia(createPinia())
store = useSystemStatsStore() store = useSystemStatsStore()
vi.clearAllMocks() vi.clearAllMocks()
}) })
it('should initialize with null systemStats', () => { it('should initialize and start fetching immediately', async () => {
expect(store.systemStats).toBeNull() // useAsyncState with immediate: true starts loading right away
expect(store.isLoading).toBe(false) // In test environment, the mock resolves immediately so loading might be false already
expect(store.error).toBeNull() expect(store.systemStats).toBeNull() // Initial value is null
expect(store.error).toBeUndefined()
// Wait for initial fetch to complete
await new Promise((resolve) => setTimeout(resolve, 0))
expect(store.isInitialized).toBe(true) // Should be initialized after fetch
}) })
describe('fetchSystemStats', () => { describe('refetchSystemStats', () => {
it('should fetch system stats successfully', async () => { it('should fetch system stats successfully', async () => {
const mockStats = { const mockStats = {
system: { system: {
@@ -51,11 +58,12 @@ describe('useSystemStatsStore', () => {
vi.mocked(api.getSystemStats).mockResolvedValue(mockStats) vi.mocked(api.getSystemStats).mockResolvedValue(mockStats)
await store.fetchSystemStats() await store.refetchSystemStats()
expect(store.systemStats).toEqual(mockStats) expect(store.systemStats).toEqual(mockStats)
expect(store.isLoading).toBe(false) expect(store.isLoading).toBe(false)
expect(store.error).toBeNull() expect(store.error).toBeUndefined() // useAsyncState uses undefined for no error
expect(store.isInitialized).toBe(true)
expect(api.getSystemStats).toHaveBeenCalled() expect(api.getSystemStats).toHaveBeenCalled()
}) })
@@ -63,19 +71,19 @@ describe('useSystemStatsStore', () => {
const error = new Error('API Error') const error = new Error('API Error')
vi.mocked(api.getSystemStats).mockRejectedValue(error) vi.mocked(api.getSystemStats).mockRejectedValue(error)
await store.fetchSystemStats() await store.refetchSystemStats()
expect(store.systemStats).toBeNull() expect(store.systemStats).toBeNull() // Initial value stays null on error
expect(store.isLoading).toBe(false) expect(store.isLoading).toBe(false)
expect(store.error).toBe('API Error') expect(store.error).toEqual(error) // useAsyncState stores the actual error object
}) })
it('should handle non-Error objects', async () => { it('should handle non-Error objects', async () => {
vi.mocked(api.getSystemStats).mockRejectedValue('String error') vi.mocked(api.getSystemStats).mockRejectedValue('String error')
await store.fetchSystemStats() await store.refetchSystemStats()
expect(store.error).toBe('An error occurred while fetching system stats') expect(store.error).toBe('String error') // useAsyncState stores the actual error
}) })
it('should set loading state correctly', async () => { it('should set loading state correctly', async () => {
@@ -85,7 +93,7 @@ describe('useSystemStatsStore', () => {
}) })
vi.mocked(api.getSystemStats).mockReturnValue(promise) vi.mocked(api.getSystemStats).mockReturnValue(promise)
const fetchPromise = store.fetchSystemStats() const fetchPromise = store.refetchSystemStats()
expect(store.isLoading).toBe(true) expect(store.isLoading).toBe(true)
resolvePromise({}) resolvePromise({})
@@ -112,11 +120,12 @@ describe('useSystemStatsStore', () => {
vi.mocked(api.getSystemStats).mockResolvedValue(updatedStats) vi.mocked(api.getSystemStats).mockResolvedValue(updatedStats)
await store.fetchSystemStats() await store.refetchSystemStats()
expect(store.systemStats).toEqual(updatedStats) expect(store.systemStats).toEqual(updatedStats)
expect(store.isLoading).toBe(false) expect(store.isLoading).toBe(false)
expect(store.error).toBeNull() expect(store.error).toBeUndefined()
expect(store.isInitialized).toBe(true)
expect(api.getSystemStats).toHaveBeenCalled() expect(api.getSystemStats).toHaveBeenCalled()
}) })
}) })

View File

@@ -13,10 +13,11 @@ vi.mock('@/config', () => ({
vi.mock('@/stores/systemStatsStore') vi.mock('@/stores/systemStatsStore')
// Mock useStorage from VueUse // Mock useStorage and until from VueUse
const mockDismissalStorage = ref({} as Record<string, number>) const mockDismissalStorage = ref({} as Record<string, number>)
vi.mock('@vueuse/core', () => ({ vi.mock('@vueuse/core', () => ({
useStorage: vi.fn(() => mockDismissalStorage) useStorage: vi.fn(() => mockDismissalStorage),
until: vi.fn(() => Promise.resolve())
})) }))
describe('useVersionCompatibilityStore', () => { describe('useVersionCompatibilityStore', () => {
@@ -31,7 +32,8 @@ describe('useVersionCompatibilityStore', () => {
mockSystemStatsStore = { mockSystemStatsStore = {
systemStats: null, systemStats: null,
fetchSystemStats: vi.fn() isInitialized: false,
refetchSystemStats: vi.fn()
} }
vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore) vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
@@ -51,6 +53,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.25.0' required_frontend_version: '1.25.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility() await store.checkVersionCompatibility()
@@ -68,6 +71,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.23.0' required_frontend_version: '1.23.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility() await store.checkVersionCompatibility()
@@ -83,6 +87,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.24.0' required_frontend_version: '1.24.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility() await store.checkVersionCompatibility()
@@ -98,6 +103,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '' required_frontend_version: ''
} }
} }
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility() await store.checkVersionCompatibility()
@@ -113,6 +119,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: 'not-a-version' // invalid semver format required_frontend_version: 'not-a-version' // invalid semver format
} }
} }
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility() await store.checkVersionCompatibility()
@@ -129,6 +136,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.23.0' // Required is 1.23.0, frontend 1.24.0 meets this required_frontend_version: '1.23.0' // Required is 1.23.0, frontend 1.24.0 meets this
} }
} }
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility() await store.checkVersionCompatibility()
@@ -148,6 +156,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.25.0' required_frontend_version: '1.25.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility() await store.checkVersionCompatibility()
@@ -167,6 +176,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.25.0' required_frontend_version: '1.25.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility() await store.checkVersionCompatibility()
@@ -180,6 +190,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.24.0' required_frontend_version: '1.24.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility() await store.checkVersionCompatibility()
@@ -195,6 +206,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.25.0' required_frontend_version: '1.25.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility() await store.checkVersionCompatibility()
@@ -212,6 +224,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.24.0' required_frontend_version: '1.24.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility() await store.checkVersionCompatibility()
@@ -230,6 +243,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.25.0' required_frontend_version: '1.25.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.checkVersionCompatibility() await store.checkVersionCompatibility()
store.dismissWarning() store.dismissWarning()
@@ -252,6 +266,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.25.0' required_frontend_version: '1.25.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.initialize() await store.initialize()
@@ -270,6 +285,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.25.0' required_frontend_version: '1.25.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.initialize() await store.initialize()
@@ -289,6 +305,7 @@ describe('useVersionCompatibilityStore', () => {
required_frontend_version: '1.26.0' required_frontend_version: '1.26.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.initialize() await store.initialize()
@@ -298,24 +315,28 @@ describe('useVersionCompatibilityStore', () => {
describe('initialization', () => { describe('initialization', () => {
it('should fetch system stats if not available', async () => { it('should fetch system stats if not available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
await store.initialize() await store.initialize()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled() expect(until).toHaveBeenCalled()
}) })
it('should not fetch system stats if already available', async () => { it('should not fetch system stats if already available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = { mockSystemStatsStore.systemStats = {
system: { system: {
comfyui_version: '1.24.0', comfyui_version: '1.24.0',
required_frontend_version: '1.24.0' required_frontend_version: '1.24.0'
} }
} }
mockSystemStatsStore.isInitialized = true
await store.initialize() await store.initialize()
expect(mockSystemStatsStore.fetchSystemStats).not.toHaveBeenCalled() expect(until).not.toHaveBeenCalled()
}) })
}) })
}) })

View File

@@ -1,194 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { api } from '@/scripts/api'
import { useExtensionStore } from '@/stores/extensionStore'
import {
ManagerUIState,
useManagerStateStore
} from '@/stores/managerStateStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
// Mock dependencies
vi.mock('@/scripts/api', () => ({
api: {
getClientFeatureFlags: vi.fn(),
getServerFeature: vi.fn()
}
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => ({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
}))
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: vi.fn()
}))
vi.mock('@/stores/systemStatsStore', () => ({
useSystemStatsStore: vi.fn()
}))
describe('useManagerStateStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('managerUIState computed', () => {
it('should return DISABLED state when --disable-manager is present', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: {
system: { argv: ['python', 'main.py', '--disable-manager'] }
}
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.DISABLED)
})
it('should return LEGACY_UI state when --enable-manager-legacy-ui is present', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: {
system: { argv: ['python', 'main.py', '--enable-manager-legacy-ui'] }
}
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.LEGACY_UI)
})
it('should return NEW_UI state when client and server both support v4', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: { system: { argv: ['python', 'main.py'] } }
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.NEW_UI)
})
it('should return LEGACY_UI state when server supports v4 but client does not', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: { system: { argv: ['python', 'main.py'] } }
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: false
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.LEGACY_UI)
})
it('should return LEGACY_UI state when legacy manager extension exists', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: { system: { argv: ['python', 'main.py'] } }
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: [{ name: 'Comfy.CustomNodesManager' }]
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.LEGACY_UI)
})
it('should return DISABLED state when feature flags are undefined', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: { system: { argv: ['python', 'main.py'] } }
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(undefined)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: undefined },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.DISABLED)
})
it('should return DISABLED state when no manager is available', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: { system: { argv: ['python', 'main.py'] } }
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(false)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: false },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.DISABLED)
})
it('should handle null systemStats gracefully', () => {
vi.mocked(useSystemStatsStore).mockReturnValue({
systemStats: null
} as any)
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(useFeatureFlags).mockReturnValue({
flags: { supportsManagerV4: true },
featureFlag: vi.fn()
} as any)
vi.mocked(useExtensionStore).mockReturnValue({
extensions: []
} as any)
const store = useManagerStateStore()
expect(store.managerUIState).toBe(ManagerUIState.NEW_UI)
})
})
})

View File

@@ -0,0 +1,254 @@
import { describe, expect, it } from 'vitest'
import { normalizePackId, normalizePackKeys } from '@/utils/packUtils'
describe('packUtils', () => {
describe('normalizePackId', () => {
it('should return pack ID unchanged when no version suffix exists', () => {
expect(normalizePackId('ComfyUI-GGUF')).toBe('ComfyUI-GGUF')
expect(normalizePackId('ComfyUI-Manager')).toBe('ComfyUI-Manager')
expect(normalizePackId('simple-pack')).toBe('simple-pack')
})
it('should remove version suffix with underscores', () => {
expect(normalizePackId('ComfyUI-GGUF@1_1_4')).toBe('ComfyUI-GGUF')
expect(normalizePackId('ComfyUI-Manager@2_0_0')).toBe('ComfyUI-Manager')
expect(normalizePackId('pack@1_0_0_beta')).toBe('pack')
})
it('should remove version suffix with dots', () => {
expect(normalizePackId('ComfyUI-GGUF@1.1.4')).toBe('ComfyUI-GGUF')
expect(normalizePackId('pack@2.0.0')).toBe('pack')
})
it('should handle multiple @ symbols by only removing after first @', () => {
expect(normalizePackId('pack@1_0_0@extra')).toBe('pack')
expect(normalizePackId('my@pack@1_0_0')).toBe('my')
})
it('should handle empty string', () => {
expect(normalizePackId('')).toBe('')
})
it('should handle pack ID with @ but no version', () => {
expect(normalizePackId('pack@')).toBe('pack')
})
it('should handle special characters in pack name', () => {
expect(normalizePackId('my-pack_v2@1_0_0')).toBe('my-pack_v2')
expect(normalizePackId('pack.with.dots@2_0_0')).toBe('pack.with.dots')
expect(normalizePackId('UPPERCASE-Pack@1_0_0')).toBe('UPPERCASE-Pack')
})
it('should handle edge cases', () => {
// Only @ symbol
expect(normalizePackId('@')).toBe('')
expect(normalizePackId('@1_0_0')).toBe('')
// Whitespace
expect(normalizePackId(' pack @1_0_0')).toBe(' pack ')
expect(normalizePackId('pack @1_0_0')).toBe('pack ')
})
})
describe('normalizePackKeys', () => {
it('should normalize all keys with version suffixes', () => {
const input = {
'ComfyUI-GGUF': { ver: '1.1.4', enabled: true },
'ComfyUI-Manager@2_0_0': { ver: '2.0.0', enabled: false },
'another-pack@1_0_0': { ver: '1.0.0', enabled: true }
}
const expected = {
'ComfyUI-GGUF': { ver: '1.1.4', enabled: true },
'ComfyUI-Manager': { ver: '2.0.0', enabled: false },
'another-pack': { ver: '1.0.0', enabled: true }
}
expect(normalizePackKeys(input)).toEqual(expected)
})
it('should handle empty object', () => {
expect(normalizePackKeys({})).toEqual({})
})
it('should handle keys without version suffixes', () => {
const input = {
pack1: { data: 'value1' },
pack2: { data: 'value2' }
}
expect(normalizePackKeys(input)).toEqual(input)
})
it('should handle mixed keys (with and without versions)', () => {
const input = {
'normal-pack': { ver: '1.0.0' },
'versioned-pack@2_0_0': { ver: '2.0.0' },
'another-normal': { ver: '3.0.0' },
'another-versioned@4_0_0': { ver: '4.0.0' }
}
const expected = {
'normal-pack': { ver: '1.0.0' },
'versioned-pack': { ver: '2.0.0' },
'another-normal': { ver: '3.0.0' },
'another-versioned': { ver: '4.0.0' }
}
expect(normalizePackKeys(input)).toEqual(expected)
})
it('should handle duplicate keys after normalization (last one wins)', () => {
const input = {
'pack@1_0_0': { ver: '1.0.0', data: 'first' },
'pack@2_0_0': { ver: '2.0.0', data: 'second' },
pack: { ver: '3.0.0', data: 'third' }
}
const result = normalizePackKeys(input)
// The exact behavior depends on object iteration order,
// but there should only be one 'pack' key in the result
expect(Object.keys(result)).toEqual(['pack'])
expect(result.pack).toBeDefined()
expect(result.pack.ver).toBeDefined()
})
it('should preserve value references', () => {
const value1 = { ver: '1.0.0', complex: { nested: 'data' } }
const value2 = { ver: '2.0.0', complex: { nested: 'data2' } }
const input = {
'pack1@1_0_0': value1,
'pack2@2_0_0': value2
}
const result = normalizePackKeys(input)
// Values should be the same references, not cloned
expect(result.pack1).toBe(value1)
expect(result.pack2).toBe(value2)
})
it('should handle special characters in keys', () => {
const input = {
'@1_0_0': { ver: '1.0.0' },
'my-pack.v2@2_0_0': { ver: '2.0.0' },
'UPPERCASE@3_0_0': { ver: '3.0.0' }
}
const expected = {
'': { ver: '1.0.0' },
'my-pack.v2': { ver: '2.0.0' },
UPPERCASE: { ver: '3.0.0' }
}
expect(normalizePackKeys(input)).toEqual(expected)
})
it('should work with different value types', () => {
const input = {
'pack1@1_0_0': 'string value',
'pack2@2_0_0': 123,
'pack3@3_0_0': null,
'pack4@4_0_0': undefined,
'pack5@5_0_0': true,
pack6: []
}
const expected = {
pack1: 'string value',
pack2: 123,
pack3: null,
pack4: undefined,
pack5: true,
pack6: []
}
expect(normalizePackKeys(input)).toEqual(expected)
})
})
describe('Integration scenarios from JSDoc examples', () => {
it('should handle the examples from normalizePackId JSDoc', () => {
expect(normalizePackId('ComfyUI-GGUF')).toBe('ComfyUI-GGUF')
expect(normalizePackId('ComfyUI-GGUF@1_1_4')).toBe('ComfyUI-GGUF')
})
it('should handle the examples from normalizePackKeys JSDoc', () => {
const input = {
'ComfyUI-GGUF': { ver: '1.1.4', enabled: true },
'ComfyUI-Manager@2_0_0': { ver: '2.0.0', enabled: false }
}
const expected = {
'ComfyUI-GGUF': { ver: '1.1.4', enabled: true },
'ComfyUI-Manager': { ver: '2.0.0', enabled: false }
}
expect(normalizePackKeys(input)).toEqual(expected)
})
})
describe('Real-world scenarios', () => {
it('should handle typical ComfyUI-Manager response with mixed enabled/disabled packs', () => {
// Simulating actual server response pattern
const serverResponse = {
// Enabled packs come without version suffix
'ComfyUI-Essential': { ver: '1.2.3', enabled: true, aux_id: undefined },
'ComfyUI-Impact': { ver: '2.0.0', enabled: true, aux_id: undefined },
// Disabled packs come with version suffix
'ComfyUI-GGUF@1_1_4': {
ver: '1.1.4',
enabled: false,
aux_id: undefined
},
'ComfyUI-Manager@2_5_0': {
ver: '2.5.0',
enabled: false,
aux_id: undefined
}
}
const normalized = normalizePackKeys(serverResponse)
// All keys should be normalized (no version suffixes)
expect(Object.keys(normalized)).toEqual([
'ComfyUI-Essential',
'ComfyUI-Impact',
'ComfyUI-GGUF',
'ComfyUI-Manager'
])
// Values should be preserved
expect(normalized['ComfyUI-GGUF']).toEqual({
ver: '1.1.4',
enabled: false,
aux_id: undefined
})
})
it('should allow consistent access by pack ID regardless of enabled state', () => {
const packsBeforeToggle = {
'my-pack': { ver: '1.0.0', enabled: true }
}
const packsAfterToggle = {
'my-pack@1_0_0': { ver: '1.0.0', enabled: false }
}
const normalizedBefore = normalizePackKeys(packsBeforeToggle)
const normalizedAfter = normalizePackKeys(packsAfterToggle)
// Both should have the same key after normalization
expect(normalizedBefore['my-pack']).toBeDefined()
expect(normalizedAfter['my-pack']).toBeDefined()
// Can access by the same key regardless of the original format
expect(Object.keys(normalizedBefore)).toEqual(
Object.keys(normalizedAfter)
)
})
})
})