Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/composables/useFeatureFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { computed, reactive, readonly } from 'vue'

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

/**
* Known server feature flags (top-level, not extensions)
*/
export enum ServerFeatureFlag {
SUPPORTS_PREVIEW_METADATA = 'supports_preview_metadata',
MAX_UPLOAD_SIZE = 'max_upload_size'
}

/**
* Composable for reactive access to feature flags
*/
export function useFeatureFlags() {
// Create reactive state that tracks server feature flags
const flags = reactive({
get supportsPreviewMetadata() {
return api.getServerFeature(ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
},
get maxUploadSize() {
return api.getServerFeature(ServerFeatureFlag.MAX_UPLOAD_SIZE)
}
})

// Create a reactive computed for any feature flag
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) => {
return computed(() => api.getServerFeature(featurePath, defaultValue))
}

return {
flags: readonly(flags),
featureFlag
}
}
9 changes: 5 additions & 4 deletions src/scripts/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios from 'axios'
import get from 'lodash/get'

import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json'
import type {
Expand Down Expand Up @@ -1049,21 +1050,21 @@ export class ComfyApi extends EventTarget {

/**
* Checks if the server supports a specific feature.
* @param featureName The name of the feature to check
* @param featureName The name of the feature to check (supports dot notation for nested values)
* @returns true if the feature is supported, false otherwise
*/
serverSupportsFeature(featureName: string): boolean {
return this.serverFeatureFlags[featureName] === true
return get(this.serverFeatureFlags, featureName) === true
}

/**
* Gets a server feature flag value.
* @param featureName The name of the feature to get
* @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
*/
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
return (this.serverFeatureFlags[featureName] ?? defaultValue) as T
return get(this.serverFeatureFlags, featureName, defaultValue) as T
}

/**
Expand Down
121 changes: 121 additions & 0 deletions tests-ui/tests/composables/useFeatureFlags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isReactive, isReadonly } from 'vue'

import {
ServerFeatureFlag,
useFeatureFlags
} from '@/composables/useFeatureFlags'
import { api } from '@/scripts/api'

// Mock the API module
vi.mock('@/scripts/api', () => ({
api: {
getServerFeature: vi.fn()
}
}))

describe('useFeatureFlags', () => {
beforeEach(() => {
vi.clearAllMocks()
})

describe('flags object', () => {
it('should provide reactive readonly flags', () => {
const { flags } = useFeatureFlags()

expect(isReadonly(flags)).toBe(true)
expect(isReactive(flags)).toBe(true)
})

it('should access supportsPreviewMetadata', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
return true as any
return defaultValue
}
)

const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA
)
})

it('should access maxUploadSize', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE)
return 209715200 as any // 200MB
return defaultValue
}
)

const { flags } = useFeatureFlags()
expect(flags.maxUploadSize).toBe(209715200)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.MAX_UPLOAD_SIZE
)
})

it('should return undefined when features are not available and no default provided', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(_path, defaultValue) => defaultValue as any
)

const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBeUndefined()
expect(flags.maxUploadSize).toBeUndefined()
})
})

describe('featureFlag', () => {
it('should create reactive computed for custom feature flags', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === 'custom.feature') return 'custom-value' as any
return defaultValue
}
)

const { featureFlag } = useFeatureFlags()
const customFlag = featureFlag('custom.feature', 'default')

expect(customFlag.value).toBe('custom-value')
expect(api.getServerFeature).toHaveBeenCalledWith(
'custom.feature',
'default'
)
})

it('should handle nested paths', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === 'extension.custom.nested.feature') return true as any
return defaultValue
}
)

const { featureFlag } = useFeatureFlags()
const nestedFlag = featureFlag('extension.custom.nested.feature', false)

expect(nestedFlag.value).toBe(true)
})

it('should work with ServerFeatureFlag enum', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.MAX_UPLOAD_SIZE)
return 104857600 as any
return defaultValue
}
)

const { featureFlag } = useFeatureFlags()
const maxUploadSize = featureFlag(ServerFeatureFlag.MAX_UPLOAD_SIZE)

expect(maxUploadSize.value).toBe(104857600)
})
})
})
Loading