From b7f778bfc81ddbbf9bd80e06162e644ece562c6d Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Thu, 7 Aug 2025 13:06:29 -0700 Subject: [PATCH] [feat] Add reactive feature flags foundation (#4817) --- src/composables/useFeatureFlags.ts | 36 ++++++ src/scripts/api.ts | 9 +- .../tests/composables/useFeatureFlags.test.ts | 121 ++++++++++++++++++ 3 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 src/composables/useFeatureFlags.ts create mode 100644 tests-ui/tests/composables/useFeatureFlags.test.ts diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts new file mode 100644 index 000000000..db3edf68c --- /dev/null +++ b/src/composables/useFeatureFlags.ts @@ -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 = (featurePath: string, defaultValue?: T) => { + return computed(() => api.getServerFeature(featurePath, defaultValue)) + } + + return { + flags: readonly(flags), + featureFlag + } +} diff --git a/src/scripts/api.ts b/src/scripts/api.ts index 0b26353f0..46732009c 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import get from 'lodash/get' import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' import type { @@ -1082,21 +1083,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(featureName: string, defaultValue?: T): T { - return (this.serverFeatureFlags[featureName] ?? defaultValue) as T + return get(this.serverFeatureFlags, featureName, defaultValue) as T } /** diff --git a/tests-ui/tests/composables/useFeatureFlags.test.ts b/tests-ui/tests/composables/useFeatureFlags.test.ts new file mode 100644 index 000000000..5b385f3c2 --- /dev/null +++ b/tests-ui/tests/composables/useFeatureFlags.test.ts @@ -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) + }) + }) +})