diff --git a/src/composables/useFeatureFlags.test.ts b/src/composables/useFeatureFlags.test.ts index c1caea6cf..0b242d4b7 100644 --- a/src/composables/useFeatureFlags.test.ts +++ b/src/composables/useFeatureFlags.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { isReactive, isReadonly } from 'vue' import { @@ -175,4 +175,49 @@ describe('useFeatureFlags', () => { expect(flags.linearToggleEnabled).toBe(false) }) }) + + describe('dev override via localStorage', () => { + afterEach(() => { + localStorage.clear() + }) + + it('resolveFlag returns localStorage override over remoteConfig and server value', () => { + vi.mocked(api.getServerFeature).mockReturnValue(false) + localStorage.setItem('ff:model_upload_button_enabled', 'true') + + const { flags } = useFeatureFlags() + expect(flags.modelUploadButtonEnabled).toBe(true) + }) + + it('resolveFlag falls through to server when no override is set', () => { + vi.mocked(api.getServerFeature).mockImplementation( + (path, defaultValue) => { + if (path === ServerFeatureFlag.ASSET_RENAME_ENABLED) return true + return defaultValue + } + ) + + const { flags } = 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 + }) + + const { flags } = useFeatureFlags() + expect(flags.supportsPreviewMetadata).toBe('overridden') + }) + + it('teamWorkspacesEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => { + vi.mocked(distributionTypes).isCloud = false + localStorage.setItem('ff:team_workspaces_enabled', 'true') + + const { flags } = useFeatureFlags() + expect(flags.teamWorkspacesEnabled).toBe(true) + }) + }) }) diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts index 19fea3070..2f2d7059f 100644 --- a/src/composables/useFeatureFlags.ts +++ b/src/composables/useFeatureFlags.ts @@ -6,6 +6,7 @@ import { remoteConfig } from '@/platform/remoteConfig/remoteConfig' import { api } from '@/scripts/api' +import { getDevOverride } from '@/utils/devFeatureFlagOverride' /** * Known server feature flags (top-level, not extensions) @@ -24,6 +25,19 @@ export enum ServerFeatureFlag { NODE_REPLACEMENTS = 'node_replacements' } +/** + * Resolves a feature flag value with dev override > remoteConfig > serverFeature priority. + */ +function resolveFlag( + flagKey: string, + remoteConfigValue: T | undefined, + defaultValue: T +): T { + const override = getDevOverride(flagKey) + if (override !== undefined) return override + return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue) +} + /** * Composable for reactive access to server-side feature flags */ @@ -39,38 +53,40 @@ export function useFeatureFlags() { return api.getServerFeature(ServerFeatureFlag.MANAGER_SUPPORTS_V4) }, get modelUploadButtonEnabled() { - return ( - remoteConfig.value.model_upload_button_enabled ?? - api.getServerFeature( - ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED, - false - ) + return resolveFlag( + ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED, + remoteConfig.value.model_upload_button_enabled, + false ) }, get assetRenameEnabled() { - return ( - remoteConfig.value.asset_rename_enabled ?? - api.getServerFeature(ServerFeatureFlag.ASSET_RENAME_ENABLED, false) + return resolveFlag( + ServerFeatureFlag.ASSET_RENAME_ENABLED, + remoteConfig.value.asset_rename_enabled, + false ) }, get privateModelsEnabled() { - return ( - remoteConfig.value.private_models_enabled ?? - api.getServerFeature(ServerFeatureFlag.PRIVATE_MODELS_ENABLED, false) + return resolveFlag( + ServerFeatureFlag.PRIVATE_MODELS_ENABLED, + remoteConfig.value.private_models_enabled, + false ) }, get onboardingSurveyEnabled() { - return ( - remoteConfig.value.onboarding_survey_enabled ?? - api.getServerFeature(ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, false) + return resolveFlag( + ServerFeatureFlag.ONBOARDING_SURVEY_ENABLED, + remoteConfig.value.onboarding_survey_enabled, + false ) }, get linearToggleEnabled() { if (isNightly) return true - return ( - remoteConfig.value.linear_toggle_enabled ?? - api.getServerFeature(ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, false) + return resolveFlag( + ServerFeatureFlag.LINEAR_TOGGLE_ENABLED, + remoteConfig.value.linear_toggle_enabled, + false ) }, /** @@ -80,11 +96,12 @@ export function useFeatureFlags() { * and prevents race conditions during initialization. */ get teamWorkspacesEnabled() { - if (!isCloud) return false + const override = getDevOverride( + ServerFeatureFlag.TEAM_WORKSPACES_ENABLED + ) + if (override !== undefined) return override - // Only return true if authenticated config has been loaded. - // This prevents race conditions where code checks this flag before - // WorkspaceAuthGate has refreshed the config with auth. + if (!isCloud) return false if (!isAuthenticatedConfigLoaded.value) return false return ( @@ -93,9 +110,10 @@ export function useFeatureFlags() { ) }, get userSecretsEnabled() { - return ( - remoteConfig.value.user_secrets_enabled ?? - api.getServerFeature(ServerFeatureFlag.USER_SECRETS_ENABLED, false) + return resolveFlag( + ServerFeatureFlag.USER_SECRETS_ENABLED, + remoteConfig.value.user_secrets_enabled, + false ) }, get nodeReplacementsEnabled() { diff --git a/src/scripts/api.featureFlags.test.ts b/src/scripts/api.featureFlags.test.ts index adc8d932d..9af257161 100644 --- a/src/scripts/api.featureFlags.test.ts +++ b/src/scripts/api.featureFlags.test.ts @@ -233,4 +233,37 @@ describe('API Feature Flags', () => { 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 e34695a27..04e9d9675 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -5,6 +5,7 @@ import { trimEnd } from 'es-toolkit' import { ref } from 'vue' import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' } +import { getDevOverride } from '@/utils/devFeatureFlagOverride' import type { ModelFile, ModelFolderInfo @@ -1299,6 +1300,8 @@ export class ComfyApi extends EventTarget { * @returns true if the feature is supported, false otherwise */ serverSupportsFeature(featureName: string): boolean { + const override = getDevOverride(featureName) + if (override !== undefined) return override return get(this.serverFeatureFlags.value, featureName) === true } @@ -1309,6 +1312,8 @@ export class ComfyApi extends EventTarget { * @returns The feature value or default */ getServerFeature(featureName: string, defaultValue?: T): T { + const override = getDevOverride(featureName) + if (override !== undefined) return override return get(this.serverFeatureFlags.value, featureName, defaultValue) as T } diff --git a/src/utils/devFeatureFlagOverride.test.ts b/src/utils/devFeatureFlagOverride.test.ts new file mode 100644 index 000000000..739921846 --- /dev/null +++ b/src/utils/devFeatureFlagOverride.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { getDevOverride } from '@/utils/devFeatureFlagOverride' + +describe('getDevOverride', () => { + afterEach(() => { + localStorage.clear() + vi.restoreAllMocks() + }) + + it('returns undefined when no override is set', () => { + expect(getDevOverride('some_flag')).toBeUndefined() + }) + + it('returns parsed boolean true', () => { + localStorage.setItem('ff:some_flag', 'true') + expect(getDevOverride('some_flag')).toBe(true) + }) + + it('returns parsed boolean false', () => { + localStorage.setItem('ff:some_flag', 'false') + expect(getDevOverride('some_flag')).toBe(false) + }) + + it('returns parsed number', () => { + localStorage.setItem('ff:max_upload_size', '209715200') + expect(getDevOverride('max_upload_size')).toBe(209715200) + }) + + it('returns parsed string', () => { + localStorage.setItem('ff:some_flag', '"hello"') + expect(getDevOverride('some_flag')).toBe('hello') + }) + + it('returns parsed object', () => { + localStorage.setItem('ff:complex', '{"nested": true}') + expect(getDevOverride>('complex')).toEqual({ + nested: true + }) + }) + + it('uses ff: prefix for localStorage keys', () => { + localStorage.setItem('some_flag', 'true') + expect(getDevOverride('some_flag')).toBeUndefined() + + localStorage.setItem('ff:some_flag', 'true') + expect(getDevOverride('some_flag')).toBe(true) + }) + + it('returns undefined and warns on invalid JSON', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + localStorage.setItem('ff:bad', 'True') + + expect(getDevOverride('bad')).toBeUndefined() + expect(warnSpy).toHaveBeenCalledWith( + '[ff] Invalid JSON for override "bad":', + 'True' + ) + }) +}) diff --git a/src/utils/devFeatureFlagOverride.ts b/src/utils/devFeatureFlagOverride.ts new file mode 100644 index 000000000..d6d1a4474 --- /dev/null +++ b/src/utils/devFeatureFlagOverride.ts @@ -0,0 +1,26 @@ +const FF_PREFIX = 'ff:' + +/** + * Gets a dev-time feature flag override from localStorage. + * Stripped from production builds via import.meta.env.DEV tree-shaking. + * + * Returns undefined (not null) as the "no override" sentinel because + * null is a valid JSON value — JSON.parse('null') returns null. + * Using undefined avoids ambiguity between "no override set" and + * "override explicitly set to null". + * + * Usage in browser console: + * localStorage.setItem('ff:team_workspaces_enabled', 'true') + * localStorage.removeItem('ff:team_workspaces_enabled') + */ +export function getDevOverride(flagKey: string): T | undefined { + if (!import.meta.env.DEV) return undefined + const raw = localStorage.getItem(`${FF_PREFIX}${flagKey}`) + if (raw === null) return undefined + try { + return JSON.parse(raw) as T + } catch { + console.warn(`[ff] Invalid JSON for override "${flagKey}":`, raw) + return undefined + } +}