feat: add serverCapabilities module and migrate consumers

Introduces src/services/serverCapabilities.ts that fetches server
capabilities via GET /api/features before app mount, replacing the
WS-based delivery as the source of truth for internal consumers.

- initServerCapabilities(): one-shot REST fetch with retry (3 attempts)
- getServerCapability(): dot-notation access with dev override support
- Internal consumers (useFeatureFlags, nodeReplacementStore,
  useManagerState) now use getServerCapability()
- api.ts methods (getServerFeature, serverSupportsFeature, etc.)
  marked @deprecated; still functional via WS for extension compat
- app.ts: direct load() call instead of feature_flags event listener

Fixes #9079
This commit is contained in:
dante01yoon
2026-02-22 23:14:23 +09:00
parent b41f162607
commit 31d4be0ffc
12 changed files with 261 additions and 177 deletions

View File

@@ -6,16 +6,12 @@ import {
useFeatureFlags
} from '@/composables/useFeatureFlags'
import * as distributionTypes from '@/platform/distribution/types'
import { api } from '@/scripts/api'
import * as serverCapabilities from '@/services/serverCapabilities'
// Mock the API module
vi.mock('@/scripts/api', () => ({
api: {
getServerFeature: vi.fn()
}
vi.mock('@/services/serverCapabilities', () => ({
getServerCapability: vi.fn()
}))
// Mock the distribution types module
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isNightly: false
@@ -35,7 +31,7 @@ describe('useFeatureFlags', () => {
})
it('should access supportsPreviewMetadata', () => {
vi.mocked(api.getServerFeature).mockImplementation(
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA) return true
return defaultValue
@@ -44,28 +40,28 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA
)
})
it('should access maxUploadSize', () => {
vi.mocked(api.getServerFeature).mockImplementation(
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE) return 209715200 // 200MB
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE) return 209715200
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.maxUploadSize).toBe(209715200)
expect(api.getServerFeature).toHaveBeenCalledWith(
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
ServerFeatureFlag.MAX_UPLOAD_SIZE
)
})
it('should access supportsManagerV4', () => {
vi.mocked(api.getServerFeature).mockImplementation(
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MANAGER_SUPPORTS_V4) return true
return defaultValue
@@ -74,13 +70,13 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.supportsManagerV4).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
ServerFeatureFlag.MANAGER_SUPPORTS_V4
)
})
it('should return undefined when features are not available and no default provided', () => {
vi.mocked(api.getServerFeature).mockImplementation(
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(_path, defaultValue) => defaultValue
)
@@ -93,7 +89,7 @@ describe('useFeatureFlags', () => {
describe('featureFlag', () => {
it('should create reactive computed for custom feature flags', () => {
vi.mocked(api.getServerFeature).mockImplementation(
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(path, defaultValue) => {
if (path === 'custom.feature') return 'custom-value'
return defaultValue
@@ -104,14 +100,14 @@ describe('useFeatureFlags', () => {
const customFlag = featureFlag('custom.feature', 'default')
expect(customFlag.value).toBe('custom-value')
expect(api.getServerFeature).toHaveBeenCalledWith(
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
'custom.feature',
'default'
)
})
it('should handle nested paths', () => {
vi.mocked(api.getServerFeature).mockImplementation(
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(path, defaultValue) => {
if (path === 'extension.custom.nested.feature') return true
return defaultValue
@@ -125,7 +121,7 @@ describe('useFeatureFlags', () => {
})
it('should work with ServerFeatureFlag enum', () => {
vi.mocked(api.getServerFeature).mockImplementation(
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE) return 104857600
return defaultValue
@@ -145,12 +141,12 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.linearToggleEnabled).toBe(true)
expect(api.getServerFeature).not.toHaveBeenCalled()
expect(serverCapabilities.getServerCapability).not.toHaveBeenCalled()
})
it('should check remote config and server feature when isNightly is false', () => {
it('should check remote config and server capability when isNightly is false', () => {
vi.mocked(distributionTypes).isNightly = false
vi.mocked(api.getServerFeature).mockImplementation(
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.LINEAR_TOGGLE_ENABLED) return true
return defaultValue
@@ -159,7 +155,7 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.linearToggleEnabled).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
ServerFeatureFlag.LINEAR_TOGGLE_ENABLED,
false
)
@@ -167,7 +163,7 @@ describe('useFeatureFlags', () => {
it('should return false when isNightly is false and flag is disabled', () => {
vi.mocked(distributionTypes).isNightly = false
vi.mocked(api.getServerFeature).mockImplementation(
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(_path, defaultValue) => defaultValue
)
@@ -182,7 +178,7 @@ describe('useFeatureFlags', () => {
})
it('resolveFlag returns localStorage override over remoteConfig and server value', () => {
vi.mocked(api.getServerFeature).mockReturnValue(false)
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(false)
localStorage.setItem('ff:model_upload_button_enabled', 'true')
const { flags } = useFeatureFlags()
@@ -190,7 +186,7 @@ describe('useFeatureFlags', () => {
})
it('resolveFlag falls through to server when no override is set', () => {
vi.mocked(api.getServerFeature).mockImplementation(
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.ASSET_RENAME_ENABLED) return true
return defaultValue
@@ -201,12 +197,14 @@ describe('useFeatureFlags', () => {
expect(flags.assetRenameEnabled).toBe(true)
})
it('direct server flags delegate override to api.getServerFeature', () => {
vi.mocked(api.getServerFeature).mockImplementation((path) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
return 'overridden'
return undefined
})
it('direct server flags use getServerCapability which handles override', () => {
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(path) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
return 'overridden'
return undefined
}
)
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBe('overridden')

View File

@@ -5,7 +5,7 @@ import {
isAuthenticatedConfigLoaded,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { getServerCapability } from '@/services/serverCapabilities'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
/**
@@ -27,7 +27,7 @@ export enum ServerFeatureFlag {
}
/**
* Resolves a feature flag value with dev override > remoteConfig > serverFeature priority.
* Resolves a feature flag value with dev override > remoteConfig > serverCapability priority.
*/
function resolveFlag<T>(
flagKey: string,
@@ -36,22 +36,22 @@ function resolveFlag<T>(
): T {
const override = getDevOverride<T>(flagKey)
if (override !== undefined) return override
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
return remoteConfigValue ?? getServerCapability(flagKey, defaultValue)
}
/**
* Composable for reactive access to server-side feature flags
* Composable for reactive access to feature flags
*/
export function useFeatureFlags() {
const flags = reactive({
get supportsPreviewMetadata() {
return api.getServerFeature(ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
return getServerCapability(ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
},
get maxUploadSize() {
return api.getServerFeature(ServerFeatureFlag.MAX_UPLOAD_SIZE)
return getServerCapability(ServerFeatureFlag.MAX_UPLOAD_SIZE)
},
get supportsManagerV4() {
return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
return getServerCapability(ServerFeatureFlag.MANAGER_SUPPORTS_V4)
},
get modelUploadButtonEnabled() {
return resolveFlag(
@@ -107,7 +107,7 @@ export function useFeatureFlags() {
return (
remoteConfig.value.team_workspaces_enabled ??
api.getServerFeature(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
getServerCapability(ServerFeatureFlag.TEAM_WORKSPACES_ENABLED, false)
)
},
get userSecretsEnabled() {
@@ -118,14 +118,14 @@ export function useFeatureFlags() {
)
},
get nodeReplacementsEnabled() {
return api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false)
return getServerCapability(ServerFeatureFlag.NODE_REPLACEMENTS, false)
},
get nodeLibraryEssentialsEnabled() {
if (isNightly || import.meta.env.DEV) return true
return (
remoteConfig.value.node_library_essentials_enabled ??
api.getServerFeature(
getServerCapability(
ServerFeatureFlag.NODE_LIBRARY_ESSENTIALS_ENABLED,
false
)
@@ -134,7 +134,7 @@ export function useFeatureFlags() {
})
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
computed(() => api.getServerFeature(featurePath, defaultValue))
computed(() => getServerCapability(featurePath, defaultValue))
return {
flags: readonly(flags),

View File

@@ -14,6 +14,7 @@ import { VueFire, VueFireAuth } from 'vuefire'
import { getFirebaseConfig } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { initServerCapabilities } from '@/services/serverCapabilities'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import App from './App.vue'
@@ -21,6 +22,8 @@ import App from './App.vue'
import './assets/css/style.css'
import { i18n } from './i18n'
await initServerCapabilities()
/**
* CRITICAL: Load remote config FIRST for cloud builds to ensure
* window.__CONFIG__is available for all modules during initialization

View File

@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ServerFeatureFlag } from '@/composables/useFeatureFlags'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import * as serverCapabilities from '@/services/serverCapabilities'
import { fetchNodeReplacements } from './nodeReplacementService'
import { useNodeReplacementStore } from './nodeReplacementStore'
@@ -17,10 +17,8 @@ vi.mock('./nodeReplacementService', () => ({
fetchNodeReplacements: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: {
getServerFeature: vi.fn()
}
vi.mock('@/services/serverCapabilities', () => ({
getServerCapability: vi.fn()
}))
function mockSettingStore(enabled: boolean) {
@@ -38,7 +36,7 @@ function mockSettingStore(enabled: boolean) {
function createStore(settingEnabled = true, serverFeatureEnabled = true) {
setActivePinia(createPinia())
mockSettingStore(settingEnabled)
vi.mocked(api.getServerFeature).mockImplementation(
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(flag: string, defaultValue?: unknown) => {
if (flag === ServerFeatureFlag.NODE_REPLACEMENTS) {
return serverFeatureEnabled

View File

@@ -5,7 +5,7 @@ import { computed, ref } from 'vue'
import { ServerFeatureFlag } from '@/composables/useFeatureFlags'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { getServerCapability } from '@/services/serverCapabilities'
import { fetchNodeReplacements } from './nodeReplacementService'
export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
@@ -18,8 +18,7 @@ export const useNodeReplacementStore = defineStore('nodeReplacement', () => {
async function load() {
if (!isEnabled.value || isLoaded.value) return
if (!api.getServerFeature(ServerFeatureFlag.NODE_REPLACEMENTS, false))
return
if (!getServerCapability(ServerFeatureFlag.NODE_REPLACEMENTS, false)) return
try {
replacements.value = await fetchNodeReplacements()

View File

@@ -1,6 +1,5 @@
import type { Mock } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick } from 'vue'
import { api } from '@/scripts/api'
@@ -39,7 +38,7 @@ describe('API Feature Flags', () => {
})
// Reset API state
api.serverFeatureFlags.value = {}
api.serverFeatureFlags = {}
// Mock getClientFeatureFlags to return test feature flags
vi.spyOn(api, 'getClientFeatureFlags').mockReturnValue({
@@ -103,7 +102,7 @@ describe('API Feature Flags', () => {
await initPromise
// Check that server features were stored
expect(api.serverFeatureFlags.value).toEqual({
expect(api.serverFeatureFlags).toEqual({
supports_preview_metadata: true,
async_execution: true,
supported_formats: ['webp', 'jpeg', 'png'],
@@ -145,14 +144,14 @@ describe('API Feature Flags', () => {
await initPromise
// Server features should remain empty
expect(api.serverFeatureFlags.value).toEqual({})
expect(api.serverFeatureFlags).toEqual({})
})
})
describe('Feature checking methods', () => {
beforeEach(() => {
// Set up some test features
api.serverFeatureFlags.value = {
api.serverFeatureFlags = {
supports_preview_metadata: true,
async_execution: false,
capabilities: ['isolated_nodes', 'dynamic_models']
@@ -209,61 +208,12 @@ describe('API Feature Flags', () => {
describe('Integration with preview messages', () => {
it('should affect preview message handling based on feature support', () => {
// Test with metadata support
api.serverFeatureFlags.value = { supports_preview_metadata: true }
api.serverFeatureFlags = { supports_preview_metadata: true }
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
// Test without metadata support
api.serverFeatureFlags.value = {}
api.serverFeatureFlags = {}
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(false)
})
})
describe('Reactivity', () => {
it('should trigger computed updates when serverFeatureFlags changes', async () => {
api.serverFeatureFlags.value = {}
const flag = computed(() =>
api.getServerFeature('supports_preview_metadata', false)
)
expect(flag.value).toBe(false)
api.serverFeatureFlags.value = { supports_preview_metadata: true }
await nextTick()
expect(flag.value).toBe(true)
})
})
describe('Dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()
})
it('getServerFeature returns localStorage override over server value', () => {
api.serverFeatureFlags.value = { some_flag: false }
localStorage.setItem('ff:some_flag', 'true')
expect(api.getServerFeature('some_flag')).toBe(true)
})
it('serverSupportsFeature returns localStorage override over server value', () => {
api.serverFeatureFlags.value = { some_flag: false }
localStorage.setItem('ff:some_flag', 'true')
expect(api.serverSupportsFeature('some_flag')).toBe(true)
})
it('getServerFeature falls through when no override is set', () => {
api.serverFeatureFlags.value = { some_flag: 'server_value' }
expect(api.getServerFeature('some_flag')).toBe('server_value')
})
it('getServerFeature override works with numeric values', () => {
api.serverFeatureFlags.value = { max_upload_size: 100 }
localStorage.setItem('ff:max_upload_size', '999')
expect(api.getServerFeature('max_upload_size')).toBe(999)
})
})
})

View File

@@ -2,7 +2,6 @@ import { promiseTimeout, until } from '@vueuse/core'
import axios from 'axios'
import { get } from 'es-toolkit/compat'
import { trimEnd } from 'es-toolkit'
import { ref } from 'vue'
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' }
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
@@ -339,10 +338,8 @@ export class ComfyApi extends EventTarget {
return { ...defaultClientFeatureFlags }
}
/**
* Feature flags received from the backend server.
*/
serverFeatureFlags = ref<Record<string, unknown>>({})
/** @deprecated Use `getServerCapability()` from `@/services/serverCapabilities` */
serverFeatureFlags: Record<string, unknown> = {}
/**
* The auth token for the comfy org account if the user is logged in.
@@ -696,12 +693,7 @@ export class ComfyApi extends EventTarget {
this.dispatchCustomEvent(msg.type, msg.data)
break
case 'feature_flags':
// Store server feature flags
this.serverFeatureFlags.value = msg.data
console.log(
'Server feature flags received:',
this.serverFeatureFlags.value
)
this.serverFeatureFlags = msg.data
this.dispatchCustomEvent('feature_flags', msg.data)
break
default:
@@ -1294,35 +1286,23 @@ export class ComfyApi extends EventTarget {
return (await axios.get(this.apiURL('/i18n'))).data
}
/**
* Checks if the server supports a specific feature.
* @param featureName The name of the feature to check (supports dot notation for nested values)
* @returns true if the feature is supported, false otherwise
*/
/** @deprecated Use `getServerCapability()` from `@/services/serverCapabilities` */
serverSupportsFeature(featureName: string): boolean {
const override = getDevOverride<boolean>(featureName)
if (override !== undefined) return override
return get(this.serverFeatureFlags.value, featureName) === true
return get(this.serverFeatureFlags, featureName) === true
}
/**
* Gets a server feature flag value.
* @param featureName The name of the feature to get (supports dot notation for nested values)
* @param defaultValue The default value if the feature is not found
* @returns The feature value or default
*/
/** @deprecated Use `getServerCapability()` from `@/services/serverCapabilities` */
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
const override = getDevOverride<T>(featureName)
if (override !== undefined) return override
return get(this.serverFeatureFlags.value, featureName, defaultValue) as T
return get(this.serverFeatureFlags, featureName, defaultValue) as T
}
/**
* Gets all server feature flags.
* @returns Copy of all server feature flags
*/
/** @deprecated Use `getServerCapability()` from `@/services/serverCapabilities` */
getServerFeatures(): Record<string, unknown> {
return { ...this.serverFeatureFlags.value }
return { ...this.serverFeatureFlags }
}
async getFuseOptions(): Promise<IFuseOptions<TemplateInfo> | null> {

View File

@@ -740,9 +740,7 @@ export class ComfyApp {
releaseSharedObjectUrl(blobUrl)
})
api.addEventListener('feature_flags', () => {
void useNodeReplacementStore().load()
})
void useNodeReplacementStore().load()
api.init()
}

View File

@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getServerCapability,
initServerCapabilities
} from '@/services/serverCapabilities'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
describe('serverCapabilities', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
supports_preview_metadata: true,
max_upload_size: 104857600,
node_replacements: false,
extension: { manager: { supports_v4: true } }
})
})
)
})
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
})
describe('initServerCapabilities', () => {
it('fetches and freezes capabilities on success', async () => {
await initServerCapabilities()
expect(getServerCapability('supports_preview_metadata')).toBe(true)
expect(getServerCapability('max_upload_size')).toBe(104857600)
})
it('retries and falls back to empty object on persistent failure', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Network error'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await initServerCapabilities()
expect(fetch).toHaveBeenCalledTimes(3)
expect(getServerCapability('supports_preview_metadata')).toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(
'Failed to fetch server capabilities after retries'
)
})
it('succeeds on retry after initial failure', async () => {
vi.mocked(fetch)
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ supports_preview_metadata: true })
} as Response)
await initServerCapabilities()
expect(fetch).toHaveBeenCalledTimes(2)
expect(getServerCapability('supports_preview_metadata')).toBe(true)
})
it('falls back to empty object on persistent non-ok response', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
json: () => Promise.resolve({})
} as Response)
await initServerCapabilities()
expect(fetch).toHaveBeenCalledTimes(3)
expect(getServerCapability('supports_preview_metadata')).toBeUndefined()
})
})
describe('getServerCapability', () => {
it('returns default value when called before init', () => {
expect(getServerCapability('some_key', 'fallback')).toBe('fallback')
})
beforeEach(async () => {
await initServerCapabilities()
})
it('returns value for existing key', () => {
expect(getServerCapability('supports_preview_metadata')).toBe(true)
})
it('returns default value for missing key', () => {
expect(getServerCapability('non_existent', 'fallback')).toBe('fallback')
})
it('supports dot notation for nested values', () => {
expect(getServerCapability('extension.manager.supports_v4')).toBe(true)
})
it('returns undefined for missing key with no default', () => {
expect(getServerCapability('missing_key')).toBeUndefined()
})
})
describe('dev override via localStorage', () => {
beforeEach(async () => {
await initServerCapabilities()
})
afterEach(() => {
localStorage.clear()
})
it('returns localStorage override over server value', () => {
localStorage.setItem('ff:supports_preview_metadata', 'false')
expect(getServerCapability('supports_preview_metadata')).toBe(false)
})
it('falls through to server value when no override is set', () => {
expect(getServerCapability('supports_preview_metadata')).toBe(true)
})
it('override works with numeric values', () => {
localStorage.setItem('ff:max_upload_size', '999')
expect(getServerCapability('max_upload_size')).toBe(999)
})
})
})

View File

@@ -0,0 +1,41 @@
import { get } from 'es-toolkit/compat'
import { isCloud } from '@/platform/distribution/types'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
const EMPTY: Readonly<Record<string, unknown>> = Object.freeze({})
const MAX_RETRIES = 2
let capabilities: Readonly<Record<string, unknown>> = EMPTY
function getApiBase(): string {
return isCloud ? '' : location.pathname.split('/').slice(0, -1).join('/')
}
export async function initServerCapabilities(): Promise<void> {
const url = `${getApiBase()}/api/features`
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const res = await fetch(url, { cache: 'no-store' })
if (res.ok) {
capabilities = Object.freeze(await res.json())
return
}
} catch {
// Retry on network errors
}
}
console.warn('Failed to fetch server capabilities after retries')
capabilities = EMPTY
}
export function getServerCapability<T = unknown>(
key: string,
defaultValue?: T
): T {
const override = getDevOverride<T>(key)
if (override !== undefined) return override
return get(capabilities, key, defaultValue) as T
}

View File

@@ -3,21 +3,24 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
import * as serverCapabilities from '@/services/serverCapabilities'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import {
ManagerUIState,
useManagerState
} from '@/workbench/extensions/manager/composables/useManagerState'
// Mock dependencies that are not stores
vi.mock('@/scripts/api', () => ({
api: {
getClientFeatureFlags: vi.fn(),
getServerFeature: vi.fn(),
getSystemStats: vi.fn()
}
}))
vi.mock('@/services/serverCapabilities', () => ({
getServerCapability: vi.fn()
}))
vi.mock('@/composables/useFeatureFlags', () => {
const featureFlag = vi.fn()
return {
@@ -66,7 +69,6 @@ describe('useManagerState', () => {
let systemStatsStore: ReturnType<typeof useSystemStatsStore>
beforeEach(() => {
// Create a fresh testing pinia and activate it for each test
setActivePinia(
createTestingPinia({
stubActions: false,
@@ -74,20 +76,16 @@ describe('useManagerState', () => {
})
)
// Initialize stores
systemStatsStore = useSystemStatsStore()
// Reset all mocks
vi.resetAllMocks()
// Set default mock returns
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(undefined)
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(undefined)
})
describe('managerUIState property', () => {
it('should return DISABLED state when --enable-manager is NOT present', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -96,7 +94,7 @@ describe('useManagerState', () => {
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: ['python', 'main.py'], // No --enable-manager flag
argv: ['python', 'main.py'],
ram_total: 16000000000,
ram_free: 8000000000
},
@@ -110,7 +108,6 @@ describe('useManagerState', () => {
})
it('should return LEGACY_UI state when --enable-manager-legacy-ui is present', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -138,7 +135,6 @@ describe('useManagerState', () => {
})
it('should return NEW_UI state when client and server both support v4', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -159,14 +155,13 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
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', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -187,14 +182,13 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: false
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return LEGACY_UI state when server does not support v4', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -213,14 +207,13 @@ describe('useManagerState', () => {
})
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(false)
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(false)
const managerState = useManagerState()
expect(managerState.managerUIState.value).toBe(ManagerUIState.LEGACY_UI)
})
it('should return NEW_UI state when server feature flags are undefined', () => {
// Set up store state
it('should return NEW_UI state when server capability is undefined', () => {
systemStatsStore.$patch({
systemStats: {
system: {
@@ -239,15 +232,15 @@ describe('useManagerState', () => {
})
vi.mocked(api.getClientFeatureFlags).mockReturnValue({})
vi.mocked(api.getServerFeature).mockReturnValue(undefined)
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(
undefined
)
const managerState = useManagerState()
// When server feature flags haven't loaded yet, default to NEW_UI
expect(managerState.managerUIState.value).toBe(ManagerUIState.NEW_UI)
})
it('should handle null systemStats gracefully', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: null,
isInitialized: true
@@ -256,17 +249,15 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
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', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -287,14 +278,13 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
const managerState = useManagerState()
expect(managerState.isManagerEnabled.value).toBe(true)
})
it('isManagerEnabled should return false when state is DISABLED', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -303,7 +293,7 @@ describe('useManagerState', () => {
embedded_python: false,
comfyui_version: '1.0.0',
pytorch_version: '2.0.0',
argv: ['python', 'main.py'], // No --enable-manager flag
argv: ['python', 'main.py'],
ram_total: 16000000000,
ram_free: 8000000000
},
@@ -317,7 +307,6 @@ describe('useManagerState', () => {
})
it('isNewManagerUI should return true when state is NEW_UI', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -338,14 +327,13 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
const managerState = useManagerState()
expect(managerState.isNewManagerUI.value).toBe(true)
})
it('isLegacyManagerUI should return true when state is LEGACY_UI', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -373,7 +361,6 @@ describe('useManagerState', () => {
})
it('shouldShowInstallButton should return true only for NEW_UI', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -394,14 +381,13 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
const managerState = useManagerState()
expect(managerState.shouldShowInstallButton.value).toBe(true)
})
it('shouldShowManagerButtons should return true when not DISABLED', () => {
// Set up store state
systemStatsStore.$patch({
systemStats: {
system: {
@@ -422,7 +408,7 @@ describe('useManagerState', () => {
vi.mocked(api.getClientFeatureFlags).mockReturnValue({
supports_manager_v4_ui: true
})
vi.mocked(api.getServerFeature).mockReturnValue(true)
vi.mocked(serverCapabilities.getServerCapability).mockReturnValue(true)
const managerState = useManagerState()
expect(managerState.shouldShowManagerButtons.value).toBe(true)

View File

@@ -4,6 +4,7 @@ import { computed, readonly } from 'vue'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { getServerCapability } from '@/services/serverCapabilities'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useCommandStore } from '@/stores/commandStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
@@ -39,7 +40,7 @@ export function useManagerState() {
const clientSupportsV4 =
api.getClientFeatureFlags().supports_manager_v4_ui ?? false
const serverSupportsV4 = api.getServerFeature(
const serverSupportsV4 = getServerCapability(
'extension.manager.supports_v4'
)
@@ -74,8 +75,7 @@ export function useManagerState() {
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
// If server capability is not set, default to NEW_UI
// 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) {