Compare commits

...

23 Commits

Author SHA1 Message Date
filtered
f32157a615 [Release] v1.26.15 (#5745)
## Bug Fixes
- Fix desktop troubleshooting screen breakage from Tailwind v4 class
change (#5744)

**Full Changelog**:
https://github.com/Comfy-Org/ComfyUI_frontend/compare/v1.26.14...v1.26.15

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5745-Release-v1-26-15-2786d73d36508116a177ff3373d3c290)
by [Unito](https://www.unito.io)
2025-09-23 18:41:51 -07:00
filtered
d78d20d5e5 Fix desktop troubleshooting screen (#5744)
Reverts changed classes from Tailwind v4 update. The original classes
are still fully functional, and it appears that the new syntax is not
yet working.

- Ref:
2b626202cf/tests/integration/post-install/troubleshootingServerStart.spec.ts-snapshots/cannot-start-server-troubleshoot-cards-post-install-win32.png

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5744-Fix-desktop-troubleshooting-screen-2786d73d365081d6b52ac2ecd9147b08)
by [Unito](https://www.unito.io)
2025-09-24 11:31:49 +10:00
filtered
7794866c85 [Release] v1.26.14 (#5740)
## What's Changed

### 🐛 Bug Fixes
- Fix overlapping elements in desktop installer - regression from
Tailwind v4 upgrade (#5736)

**Full Changelog**:
https://github.com/Comfy-Org/ComfyUI_frontend/compare/v1.26.13...v1.26.14

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5740-Release-v1-26-14-2776d73d365081e4bc99c1b40ab4ca37)
by [Unito](https://www.unito.io)
2025-09-23 16:07:46 -07:00
Comfy Org PR Bot
93d95a2936 [backport 1.26] Fix overlapping elements in desktop installer (#5736)
Backport of #5735 to `core/1.26`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5736-backport-1-26-Fix-overlapping-elements-in-desktop-installer-2776d73d3650817fa57ac0c6a8417e05)
by [Unito](https://www.unito.io)

Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-09-23 13:24:54 -07:00
Christian Byrne
75a65699d0 [Release] v1.26.13 (#5652)
## What's Changed

### 🐛 Bug Fixes
- Fix: don't immediately close missing nodes dialog if manager is
disabled (#5647)

**Full Changelog**:
https://github.com/Comfy-Org/ComfyUI_frontend/compare/v1.26.12...v1.26.13
2025-09-18 20:09:20 -07:00
Comfy Org PR Bot
bf044b743e [backport 1.26] fix: don't immediately close missing nodes dialog if manager is disabled (#5648)
Backport of #5647 to `core/1.26`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5648-backport-1-26-fix-don-t-immediately-close-missing-nodes-dialog-if-manager-is-disabled-2736d73d3650815dba5ac53f6d4578b5)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-09-18 18:15:25 -07:00
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
55 changed files with 1670 additions and 643 deletions

View File

@@ -4,7 +4,7 @@ import { NodeBadgeMode } from '../../src/types/nodeSource'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Canvas Right Click Menu', () => {
test('Can add node', async ({ comfyPage }) => {
test.skip('Can add node', async ({ comfyPage }) => {
await comfyPage.rightClickCanvas()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-menu.png')
await comfyPage.page.getByText('Add Node').click()

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",
"private": true,
"version": "1.26.9",
"version": "1.26.15",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

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

View File

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

View File

@@ -1,5 +1,11 @@
<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>
<span>{{ label }}</span>
<slot v-if="iconPosition === 'right'" name="icon"></slot>
@@ -18,6 +24,10 @@ import {
getButtonTypeClasses
} from '@/types/buttonTypes'
defineOptions({
inheritAttrs: false
})
interface IconTextButtonProps extends BaseButtonProps {
iconPosition?: 'left' | 'right'
label: string

View File

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

View File

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

View File

@@ -2,8 +2,8 @@
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
title="Some Nodes Are Missing"
message="When loading the graph, the following node types were not found"
:title="$t('loadWorkflowWarning.missingNodesTitle')"
:message="$t('loadWorkflowWarning.missingNodesDescription')"
/>
<MissingCoreNodesMessage :missing-core-nodes="missingCoreNodes" />
<ListBox
@@ -53,19 +53,15 @@
<script setup lang="ts">
import Button from 'primevue/button'
import ListBox from 'primevue/listbox'
import { computed } from 'vue'
import { computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import MissingCoreNodesMessage from '@/components/dialog/content/MissingCoreNodesMessage.vue'
import { useMissingNodes } from '@/composables/nodePack/useMissingNodes'
import { useDialogService } from '@/services/dialogService'
import { useManagerState } from '@/composables/useManagerState'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useCommandStore } from '@/stores/commandStore'
import {
ManagerUIState,
useManagerStateStore
} from '@/stores/managerStateStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useToastStore } from '@/stores/toastStore'
import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes'
@@ -81,6 +77,7 @@ const { missingNodePacks, isLoading, error, missingCoreNodes } =
useMissingNodes()
const comfyManagerStore = useComfyManagerStore()
const managerState = useManagerState()
// Check if any of the missing packs are currently being installed
const isInstalling = computed(() => {
@@ -111,48 +108,51 @@ const uniqueNodes = computed(() => {
})
})
const managerStateStore = useManagerStateStore()
// Show manager buttons unless manager is disabled
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)
const showInstallAllButton = computed(() => {
return managerStateStore.managerUIState === ManagerUIState.NEW_UI
return managerState.shouldShowInstallButton.value
})
const openManager = async () => {
const state = managerStateStore.managerUIState
switch (state) {
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
}
await managerState.openManager({
initialTab: ManagerTab.Missing,
showToastOnLegacyError: true
})
}
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 && showInstallAllButton.value) {
// 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>
<style scoped>

View File

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

View File

@@ -29,7 +29,7 @@
<!-- Conflict Warning Banner -->
<div
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>
<div class="flex flex-col gap-2 flex-1">
@@ -46,14 +46,15 @@
{{ $t('manager.conflicts.warningBanner.button') }}
</p>
</div>
<button
type="button"
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"
:aria-label="$t('g.close')"
<IconButton
class="absolute top-0 right-0"
type="transparent"
@click="dismissWarningBanner"
>
<i class="pi pi-times text-sm"></i>
</button>
<i
class="pi pi-times text-neutral-900 dark-theme:text-white text-xs"
></i>
</IconButton>
</div>
<RegistrySearchBar
v-model:searchQuery="searchQuery"
@@ -138,6 +139,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import IconButton from '@/components/button/IconButton.vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'

View File

@@ -1,6 +1,7 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -31,11 +32,14 @@ const mockInstalledPacks = {
'installed-pack': { ver: '2.0.0' }
}
const mockIsPackEnabled = vi.fn(() => true)
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
installedPacks: mockInstalledPacks,
isPackInstalled: (id: string) =>
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks]
!!mockInstalledPacks[id as keyof typeof mockInstalledPacks],
isPackEnabled: mockIsPackEnabled
}))
}))
@@ -60,6 +64,7 @@ describe('PackVersionBadge', () => {
beforeEach(() => {
mockToggle.mockReset()
mockHide.mockReset()
mockIsPackEnabled.mockReturnValue(true) // Reset to default enabled state
})
const mountComponent = ({
@@ -79,6 +84,9 @@ describe('PackVersionBadge', () => {
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
directives: {
tooltip: Tooltip
},
stubs: {
Popover: PopoverStub,
PackVersionSelectorPopover: true
@@ -229,4 +237,63 @@ describe('PackVersionBadge', () => {
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>
<div>
<div
class="inline-flex items-center gap-1 rounded-2xl text-xs cursor-pointer py-1"
:class="{ 'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill }"
aria-haspopup="true"
role="button"
tabindex="0"
@click="toggleVersionSelector"
@keydown.enter="toggleVersionSelector"
@keydown.space="toggleVersionSelector"
v-tooltip.top="
isDisabled ? $t('manager.enablePackToChangeVersion') : null
"
class="inline-flex items-center gap-1 rounded-2xl text-xs py-1"
:class="{
'bg-gray-100 dark-theme:bg-neutral-700 px-1.5': fill,
'cursor-pointer': !isDisabled,
'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
v-if="isUpdateAvailable"
class="pi pi-arrow-circle-up text-blue-600 text-xs"
/>
<span>{{ installedVersion }}</span>
<i class="pi pi-chevron-right text-xxs" />
<i v-if="!isDisabled" class="pi pi-chevron-right text-xxs" />
</div>
<Popover
@@ -61,6 +68,11 @@ const popoverRef = ref()
const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !managerStore.isPackEnabled(nodePack?.id)
)
const installedVersion = computed(() => {
if (!nodePack.id) return 'nightly'
const version =

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,12 @@
<div
ref="toolboxRef"
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">
<Panel
v-if="visible"
class="rounded-lg selection-toolbox"
class="rounded-lg selection-toolbox pointer-events-auto"
:pt="{
header: 'hidden',
content: 'p-0 flex flex-row'
@@ -83,7 +83,6 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
<style scoped>
.selection-toolbox {
transform: translateX(-50%) translateY(-120%);
will-change: transform, opacity;
}
@keyframes slideUp {

View File

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

View File

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

View File

@@ -112,16 +112,12 @@
</div>
<div
class="transition-opacity flex gap-3 h-0"
class="transition-opacity flex gap-3 h-0 items-center"
:class="{
'opacity-40': selected && selected !== 'cpu'
}"
>
<ToggleSwitch
v-model="cpuMode"
input-id="cpu-mode"
class="-translate-y-40"
/>
<ToggleSwitch v-model="cpuMode" input-id="cpu-mode" />
<label for="cpu-mode" class="select-none">
{{ $t('install.gpuSelection.enableCpuMode') }}
</label>

View File

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

View File

@@ -106,16 +106,13 @@ import { useI18n } from 'vue-i18n'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.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 { useDialogStore } from '@/stores/dialogStore'
import {
ManagerUIState,
useManagerStateStore
} from '@/stores/managerStateStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { ManagerTab } from '@/types/comfyManagerTypes'
import { showNativeSystemMenu } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
@@ -127,6 +124,8 @@ const dialogStore = useDialogStore()
const settingStore = useSettingStore()
const { t } = useI18n()
const managerState = useManagerState()
const menuRef = ref<
({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null
>(null)
@@ -159,29 +158,11 @@ const showSettings = (defaultPanel?: string) => {
})
}
const managerStateStore = useManagerStateStore()
const showManageExtensions = async () => {
const state = managerStateStore.managerUIState
switch (state) {
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
}
await managerState.openManager({
initialTab: ManagerTab.All,
showToastOnLegacyError: false
})
}
const extraMenuItems = computed<MenuItem[]>(() => [

View File

@@ -1053,7 +1053,7 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
) as IComboWidget
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)
@@ -1061,13 +1061,13 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
String(generateAudioWidget.value).toLowerCase() === 'true'
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')) {
return generateAudio ? '$6.00/Run' : '$4.00/Run'
return generateAudio ? '$3.20/Run' : '$1.60/Run'
}
// Default fallback
return '$2.00-6.00/Run'
return '$0.80-3.20/Run'
}
},
LumaImageNode: {
@@ -1502,6 +1502,32 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
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: {
displayPrice: byteDanceVideoPricingCalculator
},
@@ -1604,6 +1630,11 @@ export const useNodePricing = () => {
// ByteDance
ByteDanceImageNode: ['model'],
ByteDanceImageEditNode: ['model'],
ByteDanceSeedreamNode: [
'model',
'sequential_image_generation',
'max_images'
],
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],

View File

@@ -44,9 +44,24 @@ export const useUpdateAvailableNodes = () => {
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(() => {
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
@@ -58,7 +73,9 @@ export const useUpdateAvailableNodes = () => {
return {
updateAvailableNodePacks,
enabledUpdateAvailableNodePacks,
hasUpdateAvailable,
hasDisabledUpdatePacks,
isLoading,
error
}

View File

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

View File

@@ -1,3 +1,4 @@
import { until } from '@vueuse/core'
import { uniqBy } from 'es-toolkit/compat'
import { computed, getCurrentInstance, onUnmounted, readonly, ref } from 'vue'
@@ -21,6 +22,7 @@ import type {
NodePackRequirements,
SystemEnvironment
} from '@/types/conflictDetectionTypes'
import { normalizePackId } from '@/utils/packUtils'
import {
cleanVersion,
satisfiesVersion,
@@ -78,9 +80,8 @@ export function useConflictDetection() {
try {
// Get system stats from store (primary source of system information)
const systemStatsStore = useSystemStatsStore()
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
// Wait for systemStats to be initialized if not already
await until(systemStatsStore.isInitialized)
// Fetch version information from backend (with error resilience)
const [frontendVersion] = await Promise.allSettled([
@@ -127,7 +128,7 @@ export function useConflictDetection() {
}
systemEnvironment.value = environment
console.log(
console.debug(
'[ConflictDetection] System environment detection completed:',
environment
)
@@ -427,7 +428,7 @@ export function useConflictDetection() {
Object.entries(bulkResult).forEach(([packageId, failInfo]) => {
if (failInfo !== null) {
importFailures[packageId] = failInfo
console.log(
console.debug(
`[ConflictDetection] Import failure found for ${packageId}:`,
failInfo
)
@@ -500,7 +501,7 @@ export function useConflictDetection() {
*/
async function performConflictDetection(): Promise<ConflictDetectionResponse> {
if (isDetecting.value) {
console.log('[ConflictDetection] Already detecting, skipping')
console.debug('[ConflictDetection] Already detecting, skipping')
return {
success: false,
error_message: 'Already detecting conflicts',
@@ -556,7 +557,10 @@ export function useConflictDetection() {
detectionSummary.value = summary
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
// 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
const mergedConflicts = mergeConflictsByPackageName(conflictedResults)
console.log(
console.debug(
'[ConflictDetection] Conflicts detected (stored for UI):',
mergedConflicts
)
@@ -632,11 +636,22 @@ export function useConflictDetection() {
/**
* Error-resilient initialization (called on app mount).
* 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> {
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
await performConflictDetection()
} catch (error) {
@@ -671,13 +686,13 @@ export function useConflictDetection() {
* Check if conflicts should trigger modal display after "What's New" dismissal
*/
async function shouldShowConflictModalAfterUpdate(): Promise<boolean> {
console.log(
console.debug(
'[ConflictDetection] Checking if conflict modal should show after update...'
)
// Ensure conflict detection has run
if (detectionResults.value.length === 0) {
console.log(
console.debug(
'[ConflictDetection] No detection results, running conflict detection...'
)
await performConflictDetection()
@@ -689,7 +704,7 @@ export function useConflictDetection() {
const hasActualConflicts = hasConflicts.value
const canShowModal = acknowledgment.shouldShowConflictModal.value
console.log('[ConflictDetection] Modal check:', {
console.debug('[ConflictDetection] Modal check:', {
hasConflicts: hasActualConflicts,
canShowModal: canShowModal,
conflictedPackagesCount: conflictedPackages.value.length
@@ -860,9 +875,7 @@ function mergeConflictsByPackageName(
conflicts.forEach((conflict) => {
// Normalize package name by removing version suffix (@1_0_3) for consistent merging
const normalizedPackageName = conflict.package_name.includes('@')
? conflict.package_name.substring(0, conflict.package_name.indexOf('@'))
: conflict.package_name
const normalizedPackageName = normalizePackId(conflict.package_name)
if (mergedMap.has(normalizedPackageName)) {
// Package already exists, merge conflicts

View File

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

View File

@@ -5,6 +5,7 @@ import { Ref, computed, ref } from 'vue'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { components } from '@/types/generatedManagerTypes'
import { normalizePackKeys } from '@/utils/packUtils'
type ManagerTaskHistory = Record<
string,
@@ -98,7 +99,8 @@ export const useManagerQueue = (
taskHistory.value = filterHistoryByClientId(state.history)
if (state.installed_packs) {
installedPacks.value = state.installed_packs
// Normalize pack keys to ensure consistent access
installedPacks.value = normalizePackKeys(state.installed_packs)
}
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",
"updateAll": "Update All",
"updatingAllPacks": "Updating all packages",
"disabledNodesWontUpdate": "Disabled nodes will not be updated",
"enablePackToChangeVersion": "Enable this pack to change versions",
"license": "License",
"nightlyVersion": "Nightly",
"latestVersion": "Latest",
@@ -205,6 +207,7 @@
"noDescription": "No description available",
"installSelected": "Install Selected",
"installAllMissingNodes": "Install All Missing Nodes",
"allMissingNodesInstalled": "All missing nodes have been successfully installed",
"packsSelected": "packs selected",
"mixedSelectionMessage": "Cannot perform bulk action on mixed selection",
"notAvailable": "Not Available",
@@ -1436,6 +1439,8 @@
"missingModelsMessage": "When loading the graph, the following models were not found"
},
"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.",
"outdatedVersionGeneric": "Some nodes require a newer version of ComfyUI. Please update to use all nodes.",
"coreNodesFromVersion": "Requires ComfyUI {version}:"

View File

@@ -2,6 +2,7 @@ import axios, { AxiosError, AxiosResponse } from 'axios'
import { v4 as uuidv4 } from 'uuid'
import { ref } from 'vue'
import { useManagerState } from '@/composables/useManagerState'
import { api } from '@/scripts/api'
import { components } from '@/types/generatedManagerTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
@@ -44,11 +45,18 @@ const managerApiClient = axios.create({
/**
* Service for interacting with the ComfyUI Manager API
* 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 = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
// Check if manager service should be available
const isManagerServiceAvailable = () => {
const managerState = useManagerState()
return managerState.isNewManagerUI.value
}
const handleRequestError = (
err: unknown,
context: string,
@@ -87,6 +95,12 @@ export const useComfyManagerService = () => {
): Promise<T | null> => {
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
error.value = null
@@ -151,6 +165,10 @@ export const useComfyManagerService = () => {
) => {
const errorContext = 'Fetching bulk import failure information'
if (!params.cnr_ids?.length && !params.urls?.length) {
return {}
}
return executeRequest<components['schemas']['ImportFailInfoBulkResponse']>(
() =>
managerApiClient.post(ManagerRoute.IMPORT_FAIL_INFO_BULK, params, {

View File

@@ -1,5 +1,4 @@
import { useEventListener, whenever } from '@vueuse/core'
import { mapKeys } from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { ref, watch } from 'vue'
@@ -14,6 +13,7 @@ import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService'
import { TaskLog } from '@/types/comfyManagerTypes'
import { components } from '@/types/generatedManagerTypes'
import { normalizePackKeys } from '@/utils/packUtils'
type InstallPackParams = components['schemas']['InstallPackParams']
type InstalledPacksResponse = components['schemas']['InstalledPacksResponse']
@@ -185,12 +185,8 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
const refreshInstalledList = async () => {
const packs = await managerService.listInstalledPacks()
if (packs) {
// The keys are 'cleaned' by stripping the version suffix.
// The pack object itself (the value) still contains the version info.
const packsWithCleanedKeys = mapKeys(packs, (_value, key) => {
return key.split('@')[0]
})
installedPacks.value = packsWithCleanedKeys
// Normalize pack keys to ensure consistent access
installedPacks.value = normalizePackKeys(packs)
}
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 { computed, ref } from 'vue'
@@ -240,7 +241,7 @@ export const useReleaseStore = defineStore('release', () => {
try {
// Ensure system stats are loaded
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
await until(systemStatsStore.isInitialized)
}
const fetchedReleases = await releaseService.getReleases({

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import { normalizePackId } from '@/utils/packUtils'
export function extractCustomNodeName(
pythonModule: string | undefined
): string | null {
const modules = pythonModule?.split('.') || []
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
}

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

@@ -1,7 +1,7 @@
<template>
<BaseViewTemplate>
<div
class="max-w-(--breakpoint-sm) flex flex-col gap-8 p-8 bg-[url('/assets/images/Git-Logo-White.svg')] bg-no-repeat bg-top-right bg-origin-padding"
class="max-w-screen-sm flex flex-col gap-8 p-8 bg-[url('/assets/images/Git-Logo-White.svg')] bg-no-repeat bg-top-right bg-origin-padding"
>
<!-- Header -->
<h1 class="mt-24 text-4xl font-bold text-red-500">

View File

@@ -3,7 +3,7 @@
<div
class="min-w-full min-h-full font-sans w-screen h-screen grid justify-around text-neutral-300 bg-neutral-900 dark-theme overflow-y-auto"
>
<div class="max-w-(--breakpoint-sm) w-screen m-8 relative">
<div class="max-w-screen-sm w-screen m-8 relative">
<!-- Header -->
<h1 class="backspan pi-wrench text-4xl font-bold">
{{ t('maintenance.title') }}

View File

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

View File

@@ -505,7 +505,7 @@ describe('useNodePricing', () => {
})
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 node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-fast-generate-001' },
@@ -513,49 +513,49 @@ describe('useNodePricing', () => {
])
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 node = createMockNode('Veo3VideoGenerationNode', [
{ name: 'model', value: 'veo-3.0-fast-generate-001' },
{ 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)
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', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('Veo3VideoGenerationNode', [])
const price = getNodeDisplayPrice(node)
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)
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)
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', () => {
it('should return base 10s range for PRO 1080p on ByteDanceTextToVideoNode', () => {
const { getNodeDisplayPrice } = useNodePricing()

View File

@@ -96,7 +96,7 @@ describe.skip('useConflictDetection with Registry Store', () => {
}
const mockSystemStatsStore = {
fetchSystemStats: vi.fn(),
refetchSystemStats: vi.fn(),
systemStats: {
system: {
comfyui_version: '0.3.41',
@@ -133,7 +133,7 @@ describe.skip('useConflictDetection with Registry Store', () => {
} as any
// Reset mock functions
mockSystemStatsStore.fetchSystemStats.mockResolvedValue(undefined)
mockSystemStatsStore.refetchSystemStats.mockResolvedValue(undefined)
mockComfyManagerService.listInstalledPacks.mockReset()
mockComfyManagerService.getImportFailInfo.mockReset()
mockRegistryService.getPackByVersion.mockReset()
@@ -185,7 +185,7 @@ describe.skip('useConflictDetection with Registry Store', () => {
it('should return fallback environment information when systemStatsStore fails', async () => {
// Mock systemStatsStore failure
mockSystemStatsStore.fetchSystemStats.mockRejectedValue(
mockSystemStatsStore.refetchSystemStats.mockRejectedValue(
new Error('Store failure')
)
mockSystemStatsStore.systemStats = null
@@ -754,7 +754,7 @@ describe.skip('useConflictDetection with Registry Store', () => {
describe('error resilience with Registry Store', () => {
it('should continue execution even when system environment detection fails', async () => {
// Mock system stats store failure
mockSystemStatsStore.fetchSystemStats.mockRejectedValue(
mockSystemStatsStore.refetchSystemStats.mockRejectedValue(
new Error('Store error')
)
mockSystemStatsStore.systemStats = null
@@ -851,7 +851,7 @@ describe.skip('useConflictDetection with Registry Store', () => {
it('should handle complete system failure gracefully', async () => {
// Mock all stores/services failing
mockSystemStatsStore.fetchSystemStats.mockRejectedValue(
mockSystemStatsStore.refetchSystemStats.mockRejectedValue(
new Error('Critical error')
)
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 mockIsPackInstalled = vi.fn()
const mockGetInstalledPackVersion = vi.fn()
const mockIsPackEnabled = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
// Default setup
mockIsPackInstalled.mockReturnValue(true)
mockIsPackEnabled.mockReturnValue(true) // Default: all packs are enabled
mockGetInstalledPackVersion.mockImplementation((id: string) => {
switch (id) {
case 'pack-1':
@@ -100,7 +102,8 @@ describe('useUpdateAvailableNodes', () => {
mockUseComfyManagerStore.mockReturnValue({
isPackInstalled: mockIsPackInstalled,
getInstalledPackVersion: mockGetInstalledPackVersion
getInstalledPackVersion: mockGetInstalledPackVersion,
isPackEnabled: mockIsPackEnabled
} as any)
mockUseInstalledPacks.mockReturnValue({
@@ -357,4 +360,127 @@ describe('useUpdateAvailableNodes', () => {
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).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)
})
})
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('@/stores/settingStore')
vi.mock('@/stores/systemStatsStore')
vi.mock('@vueuse/core', () => ({
until: vi.fn(() => Promise.resolve()),
useStorage: vi.fn(() => ({ value: {} }))
}))
describe('useReleaseStore', () => {
let store: ReturnType<typeof useReleaseStore>
@@ -49,7 +53,8 @@ describe('useReleaseStore', () => {
comfyui_version: '1.0.0'
}
},
fetchSystemStats: vi.fn(),
isInitialized: true,
refetchSystemStats: vi.fn(),
getFormFactor: vi.fn(() => 'git-windows')
}
@@ -334,12 +339,15 @@ describe('useReleaseStore', () => {
})
it('should fetch system stats if not available', async () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.initialize()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
expect(until).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
})
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 () => {
const { until } = await import('@vueuse/core')
mockSystemStatsStore.systemStats = null
mockSystemStatsStore.isInitialized = false
mockReleaseService.getReleases.mockResolvedValue([mockRelease])
await store.fetchReleases()
expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
expect(until).toHaveBeenCalled()
expect(mockReleaseService.getReleases).toHaveBeenCalled()
})
})
@@ -530,7 +540,7 @@ describe('useReleaseStore', () => {
await store.initialize()
// 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 () => {

View File

@@ -21,18 +21,25 @@ describe('useSystemStatsStore', () => {
let store: ReturnType<typeof useSystemStatsStore>
beforeEach(() => {
// Mock API to prevent automatic fetch on store creation
vi.mocked(api.getSystemStats).mockResolvedValue(null as any)
setActivePinia(createPinia())
store = useSystemStatsStore()
vi.clearAllMocks()
})
it('should initialize with null systemStats', () => {
expect(store.systemStats).toBeNull()
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
it('should initialize and start fetching immediately', async () => {
// useAsyncState with immediate: true starts loading right away
// In test environment, the mock resolves immediately so loading might be false already
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 () => {
const mockStats = {
system: {
@@ -51,11 +58,12 @@ describe('useSystemStatsStore', () => {
vi.mocked(api.getSystemStats).mockResolvedValue(mockStats)
await store.fetchSystemStats()
await store.refetchSystemStats()
expect(store.systemStats).toEqual(mockStats)
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()
})
@@ -63,19 +71,19 @@ describe('useSystemStatsStore', () => {
const error = new Error('API 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.error).toBe('API Error')
expect(store.error).toEqual(error) // useAsyncState stores the actual error object
})
it('should handle non-Error objects', async () => {
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 () => {
@@ -85,7 +93,7 @@ describe('useSystemStatsStore', () => {
})
vi.mocked(api.getSystemStats).mockReturnValue(promise)
const fetchPromise = store.fetchSystemStats()
const fetchPromise = store.refetchSystemStats()
expect(store.isLoading).toBe(true)
resolvePromise({})
@@ -112,11 +120,12 @@ describe('useSystemStatsStore', () => {
vi.mocked(api.getSystemStats).mockResolvedValue(updatedStats)
await store.fetchSystemStats()
await store.refetchSystemStats()
expect(store.systemStats).toEqual(updatedStats)
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
expect(store.error).toBeUndefined()
expect(store.isInitialized).toBe(true)
expect(api.getSystemStats).toHaveBeenCalled()
})
})

View File

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