Skip to content
55 changes: 55 additions & 0 deletions src/composables/useFeatureFlags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
remoteConfigState
} from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { getSessionOverride } from '@/utils/sessionFeatureFlagOverride'

// Mock the API module
vi.mock('@/scripts/api', () => ({
Expand All @@ -21,6 +22,10 @@ vi.mock('@/scripts/api', () => ({
}
}))

vi.mock('@/utils/sessionFeatureFlagOverride', () => ({
getSessionOverride: vi.fn()
}))

// Mock the distribution types module
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
Expand Down Expand Up @@ -489,4 +494,54 @@ describe('useFeatureFlags', () => {
expect(flags.unifiedCloudAuthEnabled).toBe(true)
})
})

describe('session override precedence', () => {
afterEach(() => {
vi.mocked(getSessionOverride).mockReset()
vi.mocked(distributionTypes).isCloud = false
remoteConfigState.value = 'unloaded'
cachedBillingControlEnabled.value = undefined
localStorage.clear()
remoteConfig.value = {}
})

it('beats the dev override, remote config and the server value', () => {
vi.mocked(getSessionOverride).mockImplementation((flagKey) =>
flagKey === ServerFeatureFlag.SIGNUP_TURNSTILE ? 'enforce' : undefined
)
localStorage.setItem(
`ff:${ServerFeatureFlag.SIGNUP_TURNSTILE}`,
'"shadow"'
)
remoteConfig.value = { signup_turnstile: 'off' }
vi.mocked(api.getServerFeature).mockReturnValue('off')

const { flags } = useFeatureFlags()
expect(flags.signupTurnstileMode).toBe('enforce')
})

it('applies a false override instead of falling through to an enabled server value', () => {
vi.mocked(getSessionOverride).mockImplementation((flagKey) =>
flagKey === ServerFeatureFlag.WORKFLOW_SHARING_ENABLED
? false
: undefined
)
vi.mocked(api.getServerFeature).mockReturnValue(true)

const { flags } = useFeatureFlags()
expect(flags.workflowSharingEnabled).toBe(false)
})

it('beats the auth-window fallback on auth-gated flags', () => {
vi.mocked(distributionTypes).isCloud = true
remoteConfigState.value = 'unloaded'
cachedBillingControlEnabled.value = false
vi.mocked(getSessionOverride).mockImplementation((flagKey) =>
flagKey === ServerFeatureFlag.BILLING_CONTROL_ENABLED ? true : undefined
)

const { flags } = useFeatureFlags()
expect(flags.billingControlEnabled).toBe(true)
})
})
})
10 changes: 9 additions & 1 deletion src/composables/useFeatureFlags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@/platform/remoteConfig/remoteConfig'
import { api } from '@/scripts/api'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
import { getSessionOverride } from '@/utils/sessionFeatureFlagOverride'

/**
* Known server feature flags (top-level, not extensions)
Expand Down Expand Up @@ -42,13 +43,17 @@ export enum ServerFeatureFlag {
}

/**
* Resolves a feature flag value with dev override > remoteConfig > serverFeature priority.
* Resolves a feature flag value with session override > dev override >
* remoteConfig > serverFeature priority.
*/
function resolveFlag<T>(
flagKey: string,
remoteConfigValue: T | undefined,
defaultValue: T
): T {
const sessionOverride = getSessionOverride<T>(flagKey)
if (sessionOverride !== undefined) return sessionOverride

const override = getDevOverride<T>(flagKey)
if (override !== undefined) return override
return remoteConfigValue ?? api.getServerFeature(flagKey, defaultValue)
Expand All @@ -65,6 +70,9 @@ function resolveAuthGatedFlag(
remoteConfigValue: boolean | undefined,
cachedValue: Ref<boolean | undefined>
): boolean {
const sessionOverride = getSessionOverride<boolean>(flagKey)
if (sessionOverride !== undefined) return sessionOverride

const override = getDevOverride<boolean>(flagKey)
if (override !== undefined) return override

Expand Down
66 changes: 66 additions & 0 deletions src/scripts/api.featureFlags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,72 @@ describe('API Feature Flags', () => {
expect(autoQueueGraphChanged).toHaveBeenCalledTimes(2)
})

/**
* Pins the resolution behaviour `getServerFeature` had before any override
* layer was placed in front of it, so a future layer cannot quietly change
* how server values, falsy values, nested paths or defaults resolve.
*/
describe('characterization: resolution with no override present', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
window.history.replaceState({}, '', '/')
})

afterEach(() => {
localStorage.clear()
sessionStorage.clear()
window.history.replaceState({}, '', '/')
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('returns the server value verbatim', () => {
api.serverFeatureFlags.value = { some_flag: 'server_value' }

expect(api.getServerFeature('some_flag')).toBe('server_value')
})

it.for([
['false', false],
['zero', 0],
['empty string', '']
] as [label: string, serverValue: unknown][])(
'keeps a server value of %s instead of falling back to the default',
([, serverValue]) => {
api.serverFeatureFlags.value = { some_flag: serverValue }

expect(api.getServerFeature('some_flag', 'DEFAULT')).toBe(serverValue)
}
)

it('returns the default when the flag is absent', () => {
api.serverFeatureFlags.value = {}

expect(api.getServerFeature('missing_flag', 'DEFAULT')).toBe('DEFAULT')
})

it('returns undefined when the flag is absent and no default is given', () => {
api.serverFeatureFlags.value = {}

expect(api.getServerFeature('missing_flag')).toBeUndefined()
})

it('resolves a nested flag through dot notation', () => {
api.serverFeatureFlags.value = {
extension: { manager: { supports_v4: true } }
}

expect(api.getServerFeature('extension.manager.supports_v4')).toBe(true)
})

it('returns the default for a nested path that does not exist', () => {
api.serverFeatureFlags.value = { extension: {} }

expect(
api.getServerFeature('extension.manager.supports_v4', 'DEFAULT')
).toBe('DEFAULT')
})
})

describe('Dev override via localStorage', () => {
afterEach(() => {
localStorage.clear()
Expand Down
95 changes: 95 additions & 0 deletions src/scripts/api.sessionOverride.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { api } from '@/scripts/api'

const mockDistribution = vi.hoisted(() => ({
isCloud: true,
isNightly: false
}))
vi.mock('@/platform/distribution/types', () => mockDistribution)

const mockCurrentUser = vi.hoisted(() => ({
value: null as { email: string | null; emailVerified: boolean } | null
}))
vi.mock('vuefire', () => ({
useCurrentUser: vi.fn(() => mockCurrentUser)
}))

/**
* Every call here happens at plain module scope — no component, no `setup()`,
* no active Vue instance — which is how `api.getServerFeature` is reached from
* stores and utilities. Identity is stubbed at the `vuefire` boundary, so what
* these cases pin is the precedence and gating logic, not VueFire itself.
*
* That VueFire resolves the default Firebase app outside a component is a
* property of the real SDK and needs a real initialised app, so it is verified
* against a live Firebase app in the browser rather than here.
*/
describe('api.getServerFeature session override outside component setup', () => {
beforeEach(() => {
mockDistribution.isCloud = true
mockCurrentUser.value = { email: 'dev@comfy.org', emailVerified: true }
api.serverFeatureFlags.value = {}
sessionStorage.clear()
localStorage.clear()
})

afterEach(() => {
window.history.replaceState({}, '', '/')
api.serverFeatureFlags.value = {}
sessionStorage.clear()
localStorage.clear()
vi.restoreAllMocks()
})

it('applies a numeric override to a flag that never routes through resolveFlag', () => {
api.serverFeatureFlags.value = { max_upload_size: 100 }
window.history.replaceState({}, '', '/?ff=max_upload_size:209715200')

expect(api.getServerFeature('max_upload_size')).toBe(209715200)
})

it('does not throw when no Vue instance is active', () => {
window.history.replaceState({}, '', '/?ff=some_flag:enforce')

expect(() => api.getServerFeature('some_flag')).not.toThrow()
expect(api.getServerFeature('some_flag')).toBe('enforce')
})

it('beats the dev localStorage override', () => {
localStorage.setItem('ff:some_flag', '"from_local_storage"')
window.history.replaceState({}, '', '/?ff=some_flag:from_url')

expect(api.getServerFeature('some_flag')).toBe('from_url')
})

it('withholds the override from a non-employee', () => {
mockCurrentUser.value = { email: 'someone@gmail.com', emailVerified: true }
api.serverFeatureFlags.value = { max_upload_size: 100 }
window.history.replaceState({}, '', '/?ff=max_upload_size:209715200')

expect(api.getServerFeature('max_upload_size')).toBe(100)
})

it('leaves resolution untouched when no override is requested', () => {
api.serverFeatureFlags.value = { max_upload_size: 100 }

expect(api.getServerFeature('max_upload_size')).toBe(100)
expect(api.getServerFeature('missing', 'DEFAULT')).toBe('DEFAULT')
})

it('reports the override through serverSupportsFeature too', () => {
api.serverFeatureFlags.value = { some_flag: false }
window.history.replaceState({}, '', '/?ff=some_flag')

expect(api.serverSupportsFeature('some_flag')).toBe(true)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('turns a supported feature off through an explicit false override', () => {
api.serverFeatureFlags.value = { some_flag: true }
window.history.replaceState({}, '', '/?ff=some_flag:false')

expect(api.serverSupportsFeature('some_flag')).toBe(false)
expect(api.getServerFeature('some_flag')).toBe(false)
})
})
7 changes: 7 additions & 0 deletions src/scripts/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
shouldRemintCloudRequest
} from '@/platform/auth/unified/remintRetry'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
import { getSessionOverride } from '@/utils/sessionFeatureFlagOverride'
import type {
ModelFile,
ModelFolderInfo
Expand Down Expand Up @@ -1606,6 +1607,9 @@ export class ComfyApi extends EventTarget {
* @returns true if the feature is supported, false otherwise
*/
serverSupportsFeature(featureName: string): boolean {
const sessionOverride = getSessionOverride(featureName)
if (sessionOverride !== undefined) return sessionOverride === true

const override = getDevOverride<boolean>(featureName)
if (override !== undefined) return override
return get(this.serverFeatureFlags.value, featureName) === true
Expand All @@ -1618,6 +1622,9 @@ export class ComfyApi extends EventTarget {
* @returns The feature value or default
*/
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
const sessionOverride = getSessionOverride<T>(featureName)
if (sessionOverride !== undefined) return sessionOverride

const override = getDevOverride<T>(featureName)
if (override !== undefined) return override
return get(this.serverFeatureFlags.value, featureName, defaultValue) as T
Expand Down
Loading
Loading