Compare commits

...

18 Commits

Author SHA1 Message Date
dante01yoon
20a19165ff fix: extract shared getApiBase() to platform/distribution/types
Address review feedback by deduplicating the API base URL logic
that was present in both serverCapabilities.ts and api.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:32:58 +09:00
dante01yoon
3883606dbc Merge remote-tracking branch 'origin/main' into feat/9079-migrate-consumers 2026-03-31 12:25:08 +09:00
dante01yoon
260d66413c merge: resolve conflict with main
Resolve merge conflict in LiteGraphCanvasSplitterOverlay.vue in favor
of main's position-aware splitter state key fix (#9525). Migrate
builderSaveFlow.spec.ts to use __setServerCapability pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:54:08 +09:00
Dante
e106a43193 Merge branch 'main' into feat/9079-migrate-consumers 2026-03-26 23:54:37 +09:00
dante01yoon
b86c53a840 Merge remote-tracking branch 'origin/main' into feat/9079-migrate-consumers 2026-03-25 21:48:19 +09:00
dante01yoon
43ed77fd37 fix: restore overflow-visible on graph-canvas-panel lost in merge 2026-03-25 12:41:39 +09:00
dante01yoon
ede383b17a fix: add setServerCapability for E2E runtime overrides
getDevOverride only works in DEV mode, so E2E tests (production builds)
need a different mechanism to override capabilities at runtime.

- Add setServerCapability() to serverCapabilities.ts
- Expose it on window.__setServerCapability in main.ts
- Update E2E tests to use it instead of localStorage overrides
2026-03-25 12:20:08 +09:00
dante01yoon
c92ba4a110 fix: align tests with serverCapabilities migration
- useFeatureFlags.test.ts: update assertions for new default values (false)
- featureFlags.spec.ts: remove .value from serverFeatureFlags (no longer a ref)
- nodeLibraryEssentials.spec.ts: use localStorage dev override for feature flag
- appModeDropdownClipping.spec.ts: use localStorage dev override for feature flag
- appModeWidgetRename.spec.ts: use localStorage dev override for feature flag
2026-03-25 12:01:56 +09:00
dante01yoon
e1418f0b0a Merge remote-tracking branch 'origin/main' into feat/9079-migrate-consumers 2026-03-25 11:53:11 +09:00
dante01yoon
2e48b99d6f fix: properly isolate pre-init test with vi.resetModules()
The "before init" test was passing by coincidence because 'some_key'
wasn't in mock data, not because capabilities were truly empty. Uses
vi.resetModules() + dynamic import for a fresh module instance and
tests against a key that exists in mock data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 18:48:42 +09:00
dante01yoon
470b9a4183 fix: address PR #9094 review feedback
- Expand serverFeatureFlags JSDoc with Ref removal migration note
- Add AbortController timeout (5s) to serverCapabilities fetch
- Fix "before init" test running after init due to beforeEach scope
- Default supportsPreviewMetadata and supportsManagerV4 to false

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:06:18 +09:00
Dante
9c07862158 Merge branch 'main' into feat/9079-migrate-consumers 2026-03-06 08:10:37 +09:00
GitHub Action
265237cbb2 [automated] Apply ESLint and Oxfmt fixes 2026-03-05 10:44:29 +00:00
Dante
77a87b8756 Merge branch 'main' into feat/9079-migrate-consumers 2026-03-05 19:42:07 +09:00
dante01yoon
1eae85099e docs: add flag category comments in useFeatureFlags
Group flags by their resolution pattern: direct server-only,
resolveFlag with remoteConfig, and inline conditional logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:02:12 +09:00
dante01yoon
f43e1a27aa fix: differentiate retryable vs non-retryable errors in serverCapabilities
SyntaxError (JSON parse failure) now breaks out of the retry loop
immediately instead of retrying. Network errors continue to retry.
Added test for SyntaxError early break and deeply nested path edge case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:01:42 +09:00
Dante
a988a30480 Merge branch 'main' into feat/9079-migrate-consumers 2026-02-24 19:30:19 +09:00
dante01yoon
31d4be0ffc feat: add serverCapabilities module and migrate consumers
Introduces src/services/serverCapabilities.ts that fetches server
capabilities via GET /api/features before app mount, replacing the
WS-based delivery as the source of truth for internal consumers.

- initServerCapabilities(): one-shot REST fetch with retry (3 attempts)
- getServerCapability(): dot-notation access with dev override support
- Internal consumers (useFeatureFlags, nodeReplacementStore,
  useManagerState) now use getServerCapability()
- api.ts methods (getServerFeature, serverSupportsFeature, etc.)
  marked @deprecated; still functional via WS for extension compat
- app.ts: direct load() call instead of feature_flags event listener

Fixes #9079
2026-02-22 23:14:23 +09:00
20 changed files with 348 additions and 212 deletions

View File

@@ -61,10 +61,7 @@ async function addNode(page: Page, nodeType: string): Promise<string> {
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
window.__setServerCapability!('linear_toggle_enabled', true)
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})

View File

@@ -10,10 +10,7 @@ import {
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
window.__setServerCapability!('linear_toggle_enabled', true)
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(

View File

@@ -58,10 +58,7 @@ async function reSaveAs(
test.describe('Builder save flow', { tag: ['@ui'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
linear_toggle_enabled: true
}
window.__setServerCapability!('linear_toggle_enabled', true)
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(

View File

@@ -37,7 +37,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Monitor for server feature flags
const checkInterval = setInterval(() => {
const flags = window.app?.api?.serverFeatureFlags?.value
const flags = window.app?.api?.serverFeatureFlags
if (flags && Object.keys(flags).length > 0) {
window.__capturedMessages!.serverFeatureFlags = flags
clearInterval(checkInterval)
@@ -93,7 +93,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}) => {
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
return window.app!.api.serverFeatureFlags.value
return window.app!.api.serverFeatureFlags
})
// Verify we received real feature flags from the backend
@@ -126,8 +126,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Test that the method only returns true for boolean true values
const testResults = await comfyPage.page.evaluate(() => {
// Temporarily modify serverFeatureFlags to test behavior
const original = window.app!.api.serverFeatureFlags.value
window.app!.api.serverFeatureFlags.value = {
const original = window.app!.api.serverFeatureFlags
window.app!.api.serverFeatureFlags = {
bool_true: true,
bool_false: false,
string_value: 'yes',
@@ -144,7 +144,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
}
// Restore original
window.app!.api.serverFeatureFlags.value = original
window.app!.api.serverFeatureFlags = original
return results
})
@@ -279,8 +279,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Monitor when feature flags arrive by checking periodically
const checkFeatureFlags = setInterval(() => {
if (
window.app?.api?.serverFeatureFlags?.value
?.supports_preview_metadata !== undefined
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
) {
window.__appReadiness!.featureFlagsReceived = true
clearInterval(checkFeatureFlags)
@@ -317,8 +317,8 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
// Wait for feature flags to be received
await newPage.waitForFunction(
() =>
window.app?.api?.serverFeatureFlags?.value
?.supports_preview_metadata !== undefined,
window.app?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined,
{
timeout: 10000
}
@@ -328,7 +328,7 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
const readiness = await newPage.evaluate(() => {
return {
...window.__appReadiness,
currentFlags: window.app!.api.serverFeatureFlags.value
currentFlags: window.app!.api.serverFeatureFlags
}
})

View File

@@ -7,14 +7,9 @@ test.describe('Node Library Essentials Tab', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Enable the essentials feature flag via the reactive serverFeatureFlags ref.
// In production, this flag comes via WebSocket or remoteConfig (cloud only).
// The localhost test server has neither, so we set it directly.
// Enable the essentials feature flag via runtime capability override.
await comfyPage.page.evaluate(() => {
window.app!.api.serverFeatureFlags.value = {
...window.app!.api.serverFeatureFlags.value,
node_library_essentials_enabled: true
}
window.__setServerCapability!('node_library_essentials_enabled', true)
})
// Register a mock essential node so the essentials tab has content.

View File

@@ -38,6 +38,9 @@ declare global {
changeCount?: number
widgetValue?: unknown
// Server capabilities runtime override (exposed from main.ts)
__setServerCapability?: (key: string, value: unknown) => void
// Feature flags test globals
__capturedMessages?: CapturedMessages
__appReadiness?: AppReadiness

View File

@@ -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,29 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA,
false
)
})
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,26 +71,27 @@ describe('useFeatureFlags', () => {
const { flags } = useFeatureFlags()
expect(flags.supportsManagerV4).toBe(true)
expect(api.getServerFeature).toHaveBeenCalledWith(
ServerFeatureFlag.MANAGER_SUPPORTS_V4
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
ServerFeatureFlag.MANAGER_SUPPORTS_V4,
false
)
})
it('should return undefined when features are not available and no default provided', () => {
vi.mocked(api.getServerFeature).mockImplementation(
it('should return defaults when features are not available', () => {
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
(_path, defaultValue) => defaultValue
)
const { flags } = useFeatureFlags()
expect(flags.supportsPreviewMetadata).toBeUndefined()
expect(flags.supportsPreviewMetadata).toBe(false)
expect(flags.maxUploadSize).toBeUndefined()
expect(flags.supportsManagerV4).toBeUndefined()
expect(flags.supportsManagerV4).toBe(false)
})
})
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 +102,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 +123,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 +143,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 +157,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 +165,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 +180,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 +188,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 +199,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')

View File

@@ -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'
/**
@@ -30,7 +30,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<T>(
flagKey: string,
@@ -39,23 +39,29 @@ function resolveFlag<T>(
): T {
const override = getDevOverride<T>(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({
// Direct server-only flags — resolved via getServerCapability() only
get supportsPreviewMetadata() {
return api.getServerFeature(ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
return getServerCapability(
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA,
false
)
},
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, false)
},
// Flags with remoteConfig override — resolved via resolveFlag()
get modelUploadButtonEnabled() {
return resolveFlag(
ServerFeatureFlag.MODEL_UPLOAD_BUTTON_ENABLED,
@@ -84,6 +90,7 @@ export function useFeatureFlags() {
false
)
},
// Flags with extra conditional logic (isNightly/isCloud guards)
get linearToggleEnabled() {
if (isNightly) return true
@@ -110,7 +117,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() {
@@ -120,15 +127,16 @@ export function useFeatureFlags() {
false
)
},
// Direct server-only flags with defaults
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
)
@@ -160,7 +168,7 @@ export function useFeatureFlags() {
})
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
computed(() => api.getServerFeature(featurePath, defaultValue))
computed(() => getServerCapability(featurePath, defaultValue))
return {
flags: readonly(flags),

View File

@@ -18,6 +18,10 @@ import {
} from '@/platform/remoteConfig/remoteConfig'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import {
initServerCapabilities,
setServerCapability
} from '@/services/serverCapabilities'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import App from './App.vue'
@@ -25,6 +29,9 @@ import App from './App.vue'
import './assets/css/style.css'
import { i18n } from './i18n'
await initServerCapabilities()
window.__setServerCapability = setServerCapability
/**
* CRITICAL: Load remote config FIRST for cloud builds to ensure
* window.__CONFIG__is available for all modules during initialization

View File

@@ -16,6 +16,10 @@ const DISTRIBUTION: Distribution = __DISTRIBUTION__
export const isDesktop = DISTRIBUTION === 'desktop'
export const isCloud = DISTRIBUTION === 'cloud'
export function getApiBase(): string {
return isCloud ? '' : location.pathname.split('/').slice(0, -1).join('/')
}
/**
* Whether this is a nightly build (from main branch).
* Nightly builds may show experimental features and surveys.

View File

@@ -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

View File

@@ -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()

View File

@@ -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)
})
})
})

View File

@@ -3,8 +3,8 @@ import axios from 'axios'
import { storeToRefs } from 'pinia'
import { get } from 'es-toolkit/compat'
import { trimEnd } from 'es-toolkit'
import { ref } from 'vue'
import { getApiBase } from '@/platform/distribution/types'
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' }
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
import type {
@@ -345,9 +345,12 @@ export class ComfyApi extends EventTarget {
}
/**
* Feature flags received from the backend server.
* @deprecated Use `getServerCapability()` from `@/services/serverCapabilities`.
* Note: This field was previously a Vue `Ref`; accessing `.value` or using it as
* a reactive source no longer works. Migrate to `getServerCapability()` for
* both value access and capability observation.
*/
serverFeatureFlags = ref<Record<string, unknown>>({})
serverFeatureFlags: Record<string, unknown> = {}
/**
* The auth token for the comfy org account if the user is logged in.
@@ -371,9 +374,7 @@ export class ComfyApi extends EventTarget {
super()
this.user = ''
this.api_host = location.host
this.api_base = isCloud
? ''
: location.pathname.split('/').slice(0, -1).join('/')
this.api_base = getApiBase()
this.initialClientId = sessionStorage.getItem('clientId')
}
@@ -749,12 +750,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:
@@ -1369,35 +1365,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<boolean>(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<T = unknown>(featureName: string, defaultValue?: T): T {
const override = getDevOverride<T>(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<string, unknown> {
return { ...this.serverFeatureFlags.value }
return { ...this.serverFeatureFlags }
}
async getFuseOptions(): Promise<IFuseOptions<TemplateInfo> | null> {

View File

@@ -782,9 +782,7 @@ export class ComfyApp {
releaseSharedObjectUrl(blobUrl)
})
api.addEventListener('feature_flags', () => {
void useNodeReplacementStore().load()
})
void useNodeReplacementStore().load()
api.init()
}

View File

@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getServerCapability,
initServerCapabilities
} from '@/services/serverCapabilities'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
getApiBase: () => ''
}))
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('stops retrying on JSON parse error (SyntaxError)', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.reject(new SyntaxError('Unexpected token'))
} as unknown as Response)
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await initServerCapabilities()
expect(fetch).toHaveBeenCalledTimes(1)
expect(warnSpy).toHaveBeenCalledWith(
'[serverCapabilities] Invalid JSON response, skipping retries'
)
})
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()
})
})
it('returns default value when called before init', async () => {
vi.resetModules()
const { getServerCapability: freshGet } =
await import('@/services/serverCapabilities')
expect(freshGet('supports_preview_metadata', 'fallback')).toBe('fallback')
})
describe('getServerCapability', () => {
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()
})
it('returns undefined for deeply nested non-existent path', () => {
expect(
getServerCapability('extension.non_existent.deep.path')
).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)
})
})
})

View File

@@ -0,0 +1,56 @@
import { get } from 'es-toolkit/compat'
import { getApiBase } from '@/platform/distribution/types'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
const EMPTY: Readonly<Record<string, unknown>> = Object.freeze({})
const MAX_RETRIES = 2
let capabilities: Readonly<Record<string, unknown>> = EMPTY
export async function initServerCapabilities(): Promise<void> {
const url = `${getApiBase()}/api/features`
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const res = await fetch(url, {
cache: 'no-store',
signal: controller.signal
})
clearTimeout(timeoutId)
if (res.ok) {
capabilities = Object.freeze(await res.json())
return
}
} catch (error) {
if (error instanceof SyntaxError) {
console.warn(
'[serverCapabilities] Invalid JSON response, skipping retries'
)
break
}
}
}
console.warn('Failed to fetch server capabilities after retries')
capabilities = EMPTY
}
/**
* Override a single capability at runtime.
* Used by E2E tests to enable features not returned by the CI backend.
*/
export function setServerCapability(key: string, value: unknown): void {
capabilities = Object.freeze({ ...capabilities, [key]: value })
}
export function getServerCapability<T = unknown>(
key: string,
defaultValue?: T
): T {
const override = getDevOverride<T>(key)
if (override !== undefined) return override
return get(capabilities, key, defaultValue) as T
}

1
src/vite-env.d.ts vendored
View File

@@ -15,6 +15,7 @@ declare module '~icons/*' {
declare global {
interface Window {
__COMFYUI_FRONTEND_VERSION__: string
__setServerCapability?: (key: string, value: unknown) => void
}
interface ImportMetaEnv {

View File

@@ -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<typeof useSystemStatsStore>
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)

View File

@@ -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) {