From 2eb5c2ab911eada6e0b2d7109f57c32da44c982d Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Sun, 7 Sep 2025 06:04:09 +0900 Subject: [PATCH] fix: feature flags and manager state handling (#5317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Replace reactive feature flags with non-reactive approach - Changed managerUIState from computed to getManagerUIState() function - Ensures fresh computation on each call to avoid timing issues - Updates all consumers to use the new function-based approach - Fixes manager UI state determination issues This change addresses the reactivity issues where feature flags were not updating properly due to Vue's reactive system limitations with external API values. * fix: Add HelpCenter manager state handling and API version switching - Fixed HelpCenter manager extension to check manager state - Fixed 'Manager' command to respect manager state - Added dynamic API prefix switching based on manager state - Added debug logging for manager state determination This ensures legacy manager uses /api/ prefix and new manager uses /api/v2/ prefix * fix: Simplify manager state determination and fix API timing issues - Remove unnecessary extension check from manager state store - Use only feature flags (client and server) for state determination - Default to NEW_UI when server flags not loaded (safer default) - Fix ImportFailInfoBulk to not send empty requests - Resolves initial 404 errors on installed API calls * fix: Correct manager state determination for non-v4 servers - Fix serverSupportsV4=false returning DISABLED instead of LEGACY_UI - Server without v4 support should use legacy manager, not disable it - Clarify condition for server v4 + client non-v4 case * chore: Remove debug console.log statements - Remove all debug logging from manager state store - Remove logging from comfy manager service - Clean up code for production * test: Update manager state store tests to match new logic - Update test expectations for server feature flags undefined case (returns NEW_UI) - Update test expectations for server not supporting v4 case (returns LEGACY_UI) - Tests now correctly reflect the actual behavior of the manager state logic * fix: Remove dynamic API version handling in manager service - Remove getApiBaseURL() function and axios interceptor - Always use /api/v2/ for New Manager (hardcoded) - Add isManagerServiceAvailable() to block service calls when not in NEW_UI state - Simplify API handling as manager packages are now completely separated * refactor: Add helper functions to managerStateStore for better code reuse - Add isManagerEnabled(), isNewManagerUI(), isLegacyManagerUI() helpers - Add shouldShowInstallButton(), shouldShowManagerButtons() for UI logic - Update components to use helper functions where applicable - Add comprehensive tests for new helper functions - Centralize state checking logic to reduce duplication * fix: Ensure SystemStats is loaded before conflict detection - Move conflict detection from App.vue to GraphCanvas.vue - Check manager state before running conflict detection - Ensures SystemStats and feature flags are loaded first - Prevents unnecessary API calls when manager is disabled * docs: Clarify feature flag default behavior in manager state - Add detailed comments explaining why NEW_UI is the default - Clarify that undefined state is temporary during WebSocket connection - Document graceful error handling when server doesn't support v2 API * fix: Ensure consistent manager state handling for legacy commands - Legacy commands now show error toast in NEW_UI mode - Settings fallback for DISABLED mode - Consistent error handling across all manager entry points - Legacy commands only work in LEGACY_UI mode as expected * refactor: centralize manager opening logic into managerStateStore - Create openManager() function in managerStateStore to eliminate duplicate code - Replace 8+ repeated switch statements across different files with single function - Fix inconsistency where legacy command failure in LEGACY_UI mode incorrectly opened new manager - Add support for legacy-only commands that should show error in NEW_UI mode - Ensure all manager entry points behave consistently according to feature flags - Clean up unused imports and fix ESLint errors This addresses Christian's code review feedback about duplicate switch statements and improves maintainability by providing a single source of truth for manager opening logic. * fix: use correct i18n import in managerStateStore - Replace useI18n with direct t import from @/i18n - Fixes issue where error messages showed as numbers (e.g. '26') instead of text - Ensures toast messages display correctly in NEW_UI mode when legacy commands are invoked * feature: initial tab fix * test: Fix managerStateStore test failures by adding missing mocks The test was failing because managerStateStore imports dialogService, which imports ErrorDialogContent.vue that instantiates the app object. This caused api.addEventListener errors in tests. Added proper mocks for: - dialogService - commandStore - toastStore This prevents the problematic import chain and fixes the test failures. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * refactor: convert managerStateStore to composable - Move managerStateStore from store to composable pattern - All functions are non-reactive utilities that don't need state management - Follows Pinia guideline: "If it's not reactive, it shouldn't be in a store" - Update all import paths across 8 files - Move and update test file accordingly This change improves architecture consistency as other utility functions in the codebase also use composables rather than stores when reactivity is not required. * refactor: use readonly computed properties instead of getter methods - Convert all getter methods to readonly computed properties - Follows Vue conventions for better performance through caching - Change access pattern from function calls to .value properties - Update all usages across 6 files - Thanks to @DrJKL for the suggestion This improves performance by caching computed values and aligns with Vue's reactive system patterns. * fix: check isManagerEnabled check to GraphCanvas.vue to avoid the side-effects of calling useConflictDetection which include calling useComfyManagerStore * chore: console.log to console.debug * chore: useConflictDetection().initializeConflictDetection() * test: add mockManagerDisabled option to disable manager in Playwright tests - Add mockManagerDisabled parameter to ComfyPage.setup() (defaults to true) - Override api.getServerFeature() to return false for manager feature flag - Prevents manager initialization from interfering with subgraph tests - Individual tests can still enable manager when needed by passing mockManagerDisabled: false * chore: text modified * fix: resolve CI/CD failures by fixing manager initialization timing ## Problem GraphCanvas.vue was initializing conflict detection during component setup, causing side effects in test environment where manager is disabled. This led to 4 Playwright test failures in PR #5317. ## Root Cause - GraphCanvas.vue called useConflictDetection() in setup phase - This triggered store side effects even when manager was disabled - systemStats wasn't ready when checking manager state ## Solution 1. Removed conflict detection initialization from GraphCanvas.vue entirely 2. Refactored systemStatsStore to use VueUse's useAsyncState pattern 3. Added isInitialized check in useManagerState to wait for systemStats 4. Updated useConflictDetection to check manager state internally ## Changes - **GraphCanvas.vue**: Removed all conflict detection code - **systemStatsStore**: Implemented useAsyncState for proper async handling - **useManagerState**: Added isInitialized check before checking manager state - **useConflictDetection**: Added internal manager state validation - **App.vue**: Removed unnecessary fetchSystemStats calls - **Tests**: Updated unit tests for new async behavior ## Test Results All 4 previously failing Playwright tests now pass: - featureFlags.spec.ts (feature flag handling) - subgraph.spec.ts (breadcrumb updates, DOM cleanup) - widget.spec.ts (image changes) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * chore: modified the note * fix: test code modified * fix: when manager is new manager ui, conflict detectetion should work * fix: ensure fetch system stats before determine manager stats & when new ui & call legacy ui, open new manger dialog by default * chore: unnecessary .value deleted & fetch name modified to refetch * fix: ref type .value needed * chore: vue use until pattern for waiting initializing * fix: .value added * fix: useManagerState test to properly mock reactive refs The test was failing because it was mocking systemStats and isInitialized as plain values instead of reactive refs. The actual composable uses storeToRefs which returns refs with .value properties. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude * fix: when system stats initialized, use until(systemStatsStore.isInitialized) * fix: test --------- Co-authored-by: Claude --- src/App.vue | 2 +- .../dialog/content/ErrorDialogContent.vue | 2 +- .../dialog/content/LoadWorkflowWarning.vue | 48 +-- .../content/MissingCoreNodesMessage.vue | 22 +- .../dialog/content/setting/AboutPanel.vue | 7 - .../helpcenter/HelpCenterMenuContent.vue | 11 +- src/components/topbar/CommandMenubar.vue | 35 +- src/composables/nodePack/useWorkflowPacks.ts | 2 +- src/composables/useConflictDetection.ts | 40 ++- src/composables/useCoreCommands.ts | 147 +++----- src/composables/useManagerState.ts | 203 +++++++++++ src/services/comfyManagerService.ts | 18 + src/stores/managerStateStore.ts | 68 ---- src/stores/releaseStore.ts | 3 +- src/stores/systemStatsStore.ts | 37 +- src/stores/versionCompatibilityStore.ts | 4 +- .../content/MissingCoreNodesMessage.spec.ts | 28 +- .../composables/useConflictDetection.test.ts | 10 +- .../tests/composables/useManagerState.test.ts | 320 ++++++++++++++++++ tests-ui/tests/store/releaseStore.test.ts | 18 +- tests-ui/tests/store/systemStatsStore.test.ts | 39 ++- .../store/versionCompatibilityStore.test.ts | 31 +- .../tests/stores/managerStateStore.test.ts | 194 ----------- 23 files changed, 743 insertions(+), 546 deletions(-) create mode 100644 src/composables/useManagerState.ts delete mode 100644 src/stores/managerStateStore.ts create mode 100644 tests-ui/tests/composables/useManagerState.test.ts delete mode 100644 tests-ui/tests/stores/managerStateStore.test.ts diff --git a/src/App.vue b/src/App.vue index 1a4068a27..5e183c430 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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() diff --git a/src/components/dialog/content/ErrorDialogContent.vue b/src/components/dialog/content/ErrorDialogContent.vue index 4f35511cf..5b732ebbc 100644 --- a/src/components/dialog/content/ErrorDialogContent.vue +++ b/src/components/dialog/content/ErrorDialogContent.vue @@ -105,7 +105,7 @@ const showContactSupport = async () => { onMounted(async () => { if (!systemStatsStore.systemStats) { - await systemStatsStore.fetchSystemStats() + await systemStatsStore.refetchSystemStats() } try { diff --git a/src/components/dialog/content/LoadWorkflowWarning.vue b/src/components/dialog/content/LoadWorkflowWarning.vue index 216e54dd0..3bd571179 100644 --- a/src/components/dialog/content/LoadWorkflowWarning.vue +++ b/src/components/dialog/content/LoadWorkflowWarning.vue @@ -54,19 +54,12 @@ import Button from 'primevue/button' import ListBox from 'primevue/listbox' import { computed } 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 { useToastStore } from '@/stores/toastStore' import type { MissingNodeType } from '@/types/comfy' import { ManagerTab } from '@/types/comfyManagerTypes' @@ -81,6 +74,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,47 +105,21 @@ 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 + }) } diff --git a/src/components/dialog/content/MissingCoreNodesMessage.vue b/src/components/dialog/content/MissingCoreNodesMessage.vue index 036f088b3..cf81441f1 100644 --- a/src/components/dialog/content/MissingCoreNodesMessage.vue +++ b/src/components/dialog/content/MissingCoreNodesMessage.vue @@ -42,9 +42,8 @@ diff --git a/src/components/helpcenter/HelpCenterMenuContent.vue b/src/components/helpcenter/HelpCenterMenuContent.vue index 852b1b593..c91589407 100644 --- a/src/components/helpcenter/HelpCenterMenuContent.vue +++ b/src/components/helpcenter/HelpCenterMenuContent.vue @@ -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(() => { icon: PuzzleIcon, label: t('helpCenter.managerExtension'), showRedDot: shouldShowManagerRedDot.value, - action: () => { - dialogService.showManagerDialog() + action: async () => { + await useManagerState().openManager({ + initialTab: ManagerTab.All, + showToastOnLegacyError: false + }) emit('close') } }, diff --git a/src/components/topbar/CommandMenubar.vue b/src/components/topbar/CommandMenubar.vue index 4e01965f8..811dbd62c 100644 --- a/src/components/topbar/CommandMenubar.vue +++ b/src/components/topbar/CommandMenubar.vue @@ -83,17 +83,14 @@ 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 { useManagerState } from '@/composables/useManagerState' import { useColorPaletteService } from '@/services/colorPaletteService' -import { useDialogService } from '@/services/dialogService' 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' @@ -106,6 +103,8 @@ const dialogStore = useDialogStore() const settingStore = useSettingStore() const { t } = useI18n() +const managerState = useManagerState() + const menuRef = ref< ({ dirty: boolean } & TieredMenuMethods & TieredMenuState) | null >(null) @@ -138,29 +137,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 themeMenuItems = computed(() => { diff --git a/src/composables/nodePack/useWorkflowPacks.ts b/src/composables/nodePack/useWorkflowPacks.ts index ea7429fda..73ee14dca 100644 --- a/src/composables/nodePack/useWorkflowPacks.ts +++ b/src/composables/nodePack/useWorkflowPacks.ts @@ -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, diff --git a/src/composables/useConflictDetection.ts b/src/composables/useConflictDetection.ts index 6ec8f2cdb..8edcea41c 100644 --- a/src/composables/useConflictDetection.ts +++ b/src/composables/useConflictDetection.ts @@ -1,3 +1,4 @@ +import { until } from '@vueuse/core' import { uniqBy } from 'es-toolkit/compat' import { computed, getCurrentInstance, onUnmounted, readonly, ref } from 'vue' @@ -78,9 +79,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 +127,7 @@ export function useConflictDetection() { } systemEnvironment.value = environment - console.log( + console.debug( '[ConflictDetection] System environment detection completed:', environment ) @@ -427,7 +427,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 +500,7 @@ export function useConflictDetection() { */ async function performConflictDetection(): Promise { 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 +556,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 +571,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 +635,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 { 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 +685,13 @@ export function useConflictDetection() { * Check if conflicts should trigger modal display after "What's New" dismissal */ async function shouldShowConflictModalAfterUpdate(): Promise { - 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 +703,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 diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index 61ed889bb..ce5587104 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -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' @@ -732,34 +728,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 + }) } }, { @@ -767,33 +738,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 + }) } }, { @@ -802,32 +765,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 + }) } }, { @@ -933,8 +874,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 + }) } }, { @@ -999,18 +943,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 + }) } }, { @@ -1019,16 +956,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 + }) } }, { diff --git a/src/composables/useManagerState.ts b/src/composables/useManagerState.ts new file mode 100644 index 000000000..ae9eb1cde --- /dev/null +++ b/src/composables/useManagerState.ts @@ -0,0 +1,203 @@ +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) + if (systemStats.value?.system?.argv?.includes('--disable-manager')) { + 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 => { + 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 + } +} diff --git a/src/services/comfyManagerService.ts b/src/services/comfyManagerService.ts index 6bc09b4ae..615ddda86 100644 --- a/src/services/comfyManagerService.ts +++ b/src/services/comfyManagerService.ts @@ -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(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 => { 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( () => managerApiClient.post(ManagerRoute.IMPORT_FAIL_INFO_BULK, params, { diff --git a/src/stores/managerStateStore.ts b/src/stores/managerStateStore.ts deleted file mode 100644 index 6ba37e80d..000000000 --- a/src/stores/managerStateStore.ts +++ /dev/null @@ -1,68 +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' - ) - - // 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) - } -}) diff --git a/src/stores/releaseStore.ts b/src/stores/releaseStore.ts index 37faee5a2..a4f87281e 100644 --- a/src/stores/releaseStore.ts +++ b/src/stores/releaseStore.ts @@ -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({ diff --git a/src/stores/systemStatsStore.ts b/src/stores/systemStatsStore.ts index 07f28c229..06362930b 100644 --- a/src/stores/systemStatsStore.ts +++ b/src/stores/systemStatsStore.ts @@ -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(null) - const isLoading = ref(false) - const error = ref(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( + 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 } }) diff --git a/src/stores/versionCompatibilityStore.ts b/src/stores/versionCompatibilityStore.ts index da483846e..46b25cf33 100644 --- a/src/stores/versionCompatibilityStore.ts +++ b/src/stores/versionCompatibilityStore.ts @@ -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) } } diff --git a/tests-ui/tests/components/dialog/content/MissingCoreNodesMessage.spec.ts b/tests-ui/tests/components/dialog/content/MissingCoreNodesMessage.spec.ts index eeecae0a7..afcb90996 100644 --- a/tests-ui/tests/components/dialog/content/MissingCoreNodesMessage.spec.ts +++ b/tests-ui/tests/components/dialog/content/MissingCoreNodesMessage.spec.ts @@ -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')] diff --git a/tests-ui/tests/composables/useConflictDetection.test.ts b/tests-ui/tests/composables/useConflictDetection.test.ts index 9dfcb844a..52935f65b 100644 --- a/tests-ui/tests/composables/useConflictDetection.test.ts +++ b/tests-ui/tests/composables/useConflictDetection.test.ts @@ -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 diff --git a/tests-ui/tests/composables/useManagerState.test.ts b/tests-ui/tests/composables/useManagerState.test.ts new file mode 100644 index 000000000..dafb48a6f --- /dev/null +++ b/tests-ui/tests/composables/useManagerState.test.ts @@ -0,0 +1,320 @@ +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 --disable-manager is present', () => { + vi.mocked(useSystemStatsStore).mockReturnValue({ + systemStats: ref({ + system: { argv: ['python', 'main.py', '--disable-manager'] } + }), + 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-legacy-ui'] } + }), + 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'] } }), + 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'] } }), + 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'] } }), + 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'] } }), + 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'] } }), + 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() + + expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI) + }) + }) + + describe('helper properties', () => { + it('isManagerEnabled should return true when state is not DISABLED', () => { + vi.mocked(useSystemStatsStore).mockReturnValue({ + systemStats: ref({ system: { argv: ['python', 'main.py'] } }), + 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', '--disable-manager'] } + }), + 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'] } }), + 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-legacy-ui'] } + }), + 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'] } }), + 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'] } }), + 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) + }) + }) +}) diff --git a/tests-ui/tests/store/releaseStore.test.ts b/tests-ui/tests/store/releaseStore.test.ts index 730037f03..93a9d9dc6 100644 --- a/tests-ui/tests/store/releaseStore.test.ts +++ b/tests-ui/tests/store/releaseStore.test.ts @@ -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 @@ -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 () => { diff --git a/tests-ui/tests/store/systemStatsStore.test.ts b/tests-ui/tests/store/systemStatsStore.test.ts index 84e84ec44..67d76ee5a 100644 --- a/tests-ui/tests/store/systemStatsStore.test.ts +++ b/tests-ui/tests/store/systemStatsStore.test.ts @@ -21,18 +21,25 @@ describe('useSystemStatsStore', () => { let store: ReturnType 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() }) }) diff --git a/tests-ui/tests/store/versionCompatibilityStore.test.ts b/tests-ui/tests/store/versionCompatibilityStore.test.ts index e3d3ceca9..b5ff99ab4 100644 --- a/tests-ui/tests/store/versionCompatibilityStore.test.ts +++ b/tests-ui/tests/store/versionCompatibilityStore.test.ts @@ -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) 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() }) }) }) diff --git a/tests-ui/tests/stores/managerStateStore.test.ts b/tests-ui/tests/stores/managerStateStore.test.ts deleted file mode 100644 index 28047b12a..000000000 --- a/tests-ui/tests/stores/managerStateStore.test.ts +++ /dev/null @@ -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) - }) - }) -})