diff --git a/src/composables/useFeatureFlags.test.ts b/src/composables/useFeatureFlags.test.ts index 0b242d4b79..4ff3d80dda 100644 --- a/src/composables/useFeatureFlags.test.ts +++ b/src/composables/useFeatureFlags.test.ts @@ -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') diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts index 88e48a9b26..92dae27668 100644 --- a/src/composables/useFeatureFlags.ts +++ b/src/composables/useFeatureFlags.ts @@ -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( flagKey: string, @@ -36,22 +36,22 @@ function resolveFlag( ): T { const override = getDevOverride(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 = (featurePath: string, defaultValue?: T) => - computed(() => api.getServerFeature(featurePath, defaultValue)) + computed(() => getServerCapability(featurePath, defaultValue)) return { flags: readonly(flags), diff --git a/src/main.ts b/src/main.ts index 1ec8303453..ff3e5d532d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 diff --git a/src/platform/nodeReplacement/nodeReplacementStore.test.ts b/src/platform/nodeReplacement/nodeReplacementStore.test.ts index 4523cd8e9a..5ca76a910a 100644 --- a/src/platform/nodeReplacement/nodeReplacementStore.test.ts +++ b/src/platform/nodeReplacement/nodeReplacementStore.test.ts @@ -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 diff --git a/src/platform/nodeReplacement/nodeReplacementStore.ts b/src/platform/nodeReplacement/nodeReplacementStore.ts index bf11ba7cb3..26a9e3942b 100644 --- a/src/platform/nodeReplacement/nodeReplacementStore.ts +++ b/src/platform/nodeReplacement/nodeReplacementStore.ts @@ -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() diff --git a/src/scripts/api.featureFlags.test.ts b/src/scripts/api.featureFlags.test.ts index 9af2571614..970521c7e4 100644 --- a/src/scripts/api.featureFlags.test.ts +++ b/src/scripts/api.featureFlags.test.ts @@ -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) - }) - }) }) diff --git a/src/scripts/api.ts b/src/scripts/api.ts index 04e9d9675d..af0bb8e03e 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -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>({}) + /** @deprecated Use `getServerCapability()` from `@/services/serverCapabilities` */ + serverFeatureFlags: Record = {} /** * 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(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(featureName: string, defaultValue?: T): T { const override = getDevOverride(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 { - return { ...this.serverFeatureFlags.value } + return { ...this.serverFeatureFlags } } async getFuseOptions(): Promise | null> { diff --git a/src/scripts/app.ts b/src/scripts/app.ts index c820c5f830..4c2086f59e 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -740,9 +740,7 @@ export class ComfyApp { releaseSharedObjectUrl(blobUrl) }) - api.addEventListener('feature_flags', () => { - void useNodeReplacementStore().load() - }) + void useNodeReplacementStore().load() api.init() } diff --git a/src/services/serverCapabilities.test.ts b/src/services/serverCapabilities.test.ts new file mode 100644 index 0000000000..69959c3811 --- /dev/null +++ b/src/services/serverCapabilities.test.ts @@ -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) + }) + }) +}) diff --git a/src/services/serverCapabilities.ts b/src/services/serverCapabilities.ts new file mode 100644 index 0000000000..80cfa65129 --- /dev/null +++ b/src/services/serverCapabilities.ts @@ -0,0 +1,41 @@ +import { get } from 'es-toolkit/compat' + +import { isCloud } from '@/platform/distribution/types' +import { getDevOverride } from '@/utils/devFeatureFlagOverride' + +const EMPTY: Readonly> = Object.freeze({}) +const MAX_RETRIES = 2 + +let capabilities: Readonly> = EMPTY + +function getApiBase(): string { + return isCloud ? '' : location.pathname.split('/').slice(0, -1).join('/') +} + +export async function initServerCapabilities(): Promise { + 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( + key: string, + defaultValue?: T +): T { + const override = getDevOverride(key) + if (override !== undefined) return override + return get(capabilities, key, defaultValue) as T +} diff --git a/src/workbench/extensions/manager/composables/useManagerState.test.ts b/src/workbench/extensions/manager/composables/useManagerState.test.ts index d14fbb2fcf..2dd1032f3f 100644 --- a/src/workbench/extensions/manager/composables/useManagerState.test.ts +++ b/src/workbench/extensions/manager/composables/useManagerState.test.ts @@ -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 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) diff --git a/src/workbench/extensions/manager/composables/useManagerState.ts b/src/workbench/extensions/manager/composables/useManagerState.ts index f435a65308..a7c47ed3b8 100644 --- a/src/workbench/extensions/manager/composables/useManagerState.ts +++ b/src/workbench/extensions/manager/composables/useManagerState.ts @@ -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) {