mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
2 Commits
fix/codera
...
feat/9079-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62a945601a | ||
|
|
31d4be0ffc |
@@ -7,21 +7,16 @@ test.beforeEach(async ({ comfyPage }) => {
|
||||
})
|
||||
|
||||
test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
test('Client and server exchange feature flags on connection', async ({
|
||||
test('Client sends feature flags on WebSocket connection', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Navigate to a new page to capture the initial WebSocket connection
|
||||
const newPage = await comfyPage.page.context().newPage()
|
||||
|
||||
// Set up monitoring before navigation
|
||||
await newPage.addInitScript(() => {
|
||||
// This runs before any page scripts
|
||||
window.__capturedMessages = {
|
||||
clientFeatureFlags: null,
|
||||
serverFeatureFlags: null
|
||||
clientFeatureFlags: null
|
||||
}
|
||||
|
||||
// Capture outgoing client messages
|
||||
const originalSend = WebSocket.prototype.send
|
||||
WebSocket.prototype.send = function (data) {
|
||||
try {
|
||||
@@ -29,40 +24,22 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
if (parsed.type === 'feature_flags') {
|
||||
window.__capturedMessages!.clientFeatureFlags = parsed
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
return originalSend.call(this, data)
|
||||
}
|
||||
|
||||
// Monitor for server feature flags
|
||||
const checkInterval = setInterval(() => {
|
||||
const flags = window.app?.api?.serverFeatureFlags?.value
|
||||
if (flags && Object.keys(flags).length > 0) {
|
||||
window.__capturedMessages!.serverFeatureFlags = flags
|
||||
clearInterval(checkInterval)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
// Clear after 10 seconds
|
||||
setTimeout(() => clearInterval(checkInterval), 10000)
|
||||
})
|
||||
|
||||
// Navigate to the app
|
||||
await newPage.goto(comfyPage.url)
|
||||
|
||||
// Wait for both client and server feature flags
|
||||
await newPage.waitForFunction(
|
||||
() =>
|
||||
window.__capturedMessages!.clientFeatureFlags !== null &&
|
||||
window.__capturedMessages!.serverFeatureFlags !== null,
|
||||
() => window.__capturedMessages!.clientFeatureFlags !== null,
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
// Get the captured messages
|
||||
const messages = await newPage.evaluate(() => window.__capturedMessages)
|
||||
|
||||
// Verify client sent feature flags
|
||||
expect(messages!.clientFeatureFlags).toBeTruthy()
|
||||
expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
||||
expect(messages!.clientFeatureFlags).toHaveProperty('data')
|
||||
@@ -73,285 +50,12 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
typeof messages!.clientFeatureFlags!.data.supports_preview_metadata
|
||||
).toBe('boolean')
|
||||
|
||||
// Verify server sent feature flags back
|
||||
expect(messages!.serverFeatureFlags).toBeTruthy()
|
||||
expect(messages!.serverFeatureFlags).toHaveProperty(
|
||||
'supports_preview_metadata'
|
||||
)
|
||||
expect(typeof messages!.serverFeatureFlags!.supports_preview_metadata).toBe(
|
||||
'boolean'
|
||||
)
|
||||
expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size')
|
||||
expect(typeof messages!.serverFeatureFlags!.max_upload_size).toBe('number')
|
||||
expect(Object.keys(messages!.serverFeatureFlags!).length).toBeGreaterThan(0)
|
||||
|
||||
await newPage.close()
|
||||
})
|
||||
|
||||
test('Server feature flags are received and accessible', async ({
|
||||
test('Backend /features endpoint returns server capabilities', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Get the actual server feature flags from the backend
|
||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.serverFeatureFlags.value
|
||||
})
|
||||
|
||||
// Verify we received real feature flags from the backend
|
||||
expect(serverFlags).toBeTruthy()
|
||||
expect(Object.keys(serverFlags).length).toBeGreaterThan(0)
|
||||
|
||||
// The backend should send feature flags
|
||||
expect(serverFlags).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof serverFlags.supports_preview_metadata).toBe('boolean')
|
||||
expect(serverFlags).toHaveProperty('max_upload_size')
|
||||
expect(typeof serverFlags.max_upload_size).toBe('number')
|
||||
})
|
||||
|
||||
test('serverSupportsFeature method works with real backend flags', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Test serverSupportsFeature with real backend flags
|
||||
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.serverSupportsFeature('supports_preview_metadata')
|
||||
})
|
||||
// The method should return a boolean based on the backend's value
|
||||
expect(typeof supportsPreviewMetadata).toBe('boolean')
|
||||
|
||||
// Test non-existent feature - should always return false
|
||||
const supportsNonExistent = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.serverSupportsFeature('non_existent_feature_xyz')
|
||||
})
|
||||
expect(supportsNonExistent).toBe(false)
|
||||
|
||||
// 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 = {
|
||||
bool_true: true,
|
||||
bool_false: false,
|
||||
string_value: 'yes',
|
||||
number_value: 1,
|
||||
null_value: null
|
||||
}
|
||||
|
||||
const results = {
|
||||
bool_true: window.app!.api.serverSupportsFeature('bool_true'),
|
||||
bool_false: window.app!.api.serverSupportsFeature('bool_false'),
|
||||
string_value: window.app!.api.serverSupportsFeature('string_value'),
|
||||
number_value: window.app!.api.serverSupportsFeature('number_value'),
|
||||
null_value: window.app!.api.serverSupportsFeature('null_value')
|
||||
}
|
||||
|
||||
// Restore original
|
||||
window.app!.api.serverFeatureFlags.value = original
|
||||
return results
|
||||
})
|
||||
|
||||
// serverSupportsFeature should only return true for boolean true values
|
||||
expect(testResults.bool_true).toBe(true)
|
||||
expect(testResults.bool_false).toBe(false)
|
||||
expect(testResults.string_value).toBe(false)
|
||||
expect(testResults.number_value).toBe(false)
|
||||
expect(testResults.null_value).toBe(false)
|
||||
})
|
||||
|
||||
test('getServerFeature method works with real backend data', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Test getServerFeature method
|
||||
const previewMetadataValue = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.getServerFeature('supports_preview_metadata')
|
||||
})
|
||||
expect(typeof previewMetadataValue).toBe('boolean')
|
||||
|
||||
// Test getting max_upload_size
|
||||
const maxUploadSize = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.getServerFeature('max_upload_size')
|
||||
})
|
||||
expect(typeof maxUploadSize).toBe('number')
|
||||
expect(maxUploadSize).toBeGreaterThan(0)
|
||||
|
||||
// Test getServerFeature with default value for non-existent feature
|
||||
const defaultValue = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.getServerFeature(
|
||||
'non_existent_feature_xyz',
|
||||
'default'
|
||||
)
|
||||
})
|
||||
expect(defaultValue).toBe('default')
|
||||
})
|
||||
|
||||
test('getServerFeatures returns all backend feature flags', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Test getServerFeatures returns all flags
|
||||
const allFeatures = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.api.getServerFeatures()
|
||||
})
|
||||
|
||||
expect(allFeatures).toBeTruthy()
|
||||
expect(allFeatures).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof allFeatures.supports_preview_metadata).toBe('boolean')
|
||||
expect(allFeatures).toHaveProperty('max_upload_size')
|
||||
expect(Object.keys(allFeatures).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
||||
// Test that getClientFeatureFlags returns a copy
|
||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||
const flags1 = window.app!.api.getClientFeatureFlags()
|
||||
const flags2 = window.app!.api.getClientFeatureFlags()
|
||||
|
||||
// Modify the first object
|
||||
flags1.test_modification = true
|
||||
|
||||
// Get flags again to check if original was modified
|
||||
const flags3 = window.app!.api.getClientFeatureFlags()
|
||||
|
||||
return {
|
||||
areEqual: flags1 === flags2,
|
||||
hasModification: flags3.test_modification !== undefined,
|
||||
hasSupportsPreview: flags1.supports_preview_metadata !== undefined,
|
||||
supportsPreviewValue: flags1.supports_preview_metadata
|
||||
}
|
||||
})
|
||||
|
||||
// Verify they are different objects (not the same reference)
|
||||
expect(immutabilityTest.areEqual).toBe(false)
|
||||
|
||||
// Verify modification didn't affect the original
|
||||
expect(immutabilityTest.hasModification).toBe(false)
|
||||
|
||||
// Verify the flags contain expected properties
|
||||
expect(immutabilityTest.hasSupportsPreview).toBe(true)
|
||||
expect(typeof immutabilityTest.supportsPreviewValue).toBe('boolean') // From clientFeatureFlags.json
|
||||
})
|
||||
|
||||
test('Server features are immutable when accessed via getServerFeatures', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||
// Get a copy of server features
|
||||
const features1 = window.app!.api.getServerFeatures()
|
||||
|
||||
// Try to modify it
|
||||
features1.supports_preview_metadata = false
|
||||
features1.new_feature = 'added'
|
||||
|
||||
// Get another copy
|
||||
const features2 = window.app!.api.getServerFeatures()
|
||||
|
||||
return {
|
||||
modifiedValue: features1.supports_preview_metadata,
|
||||
originalValue: features2.supports_preview_metadata,
|
||||
hasNewFeature: features2.new_feature !== undefined,
|
||||
hasSupportsPreview: features2.supports_preview_metadata !== undefined
|
||||
}
|
||||
})
|
||||
|
||||
// The modification should only affect the copy
|
||||
expect(immutabilityTest.modifiedValue).toBe(false)
|
||||
expect(typeof immutabilityTest.originalValue).toBe('boolean') // Backend sends boolean for supports_preview_metadata
|
||||
expect(immutabilityTest.hasNewFeature).toBe(false)
|
||||
expect(immutabilityTest.hasSupportsPreview).toBe(true)
|
||||
})
|
||||
|
||||
test('Feature flags are negotiated early in connection lifecycle', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// This test verifies that feature flags are available early in the app lifecycle
|
||||
// which is important for protocol negotiation
|
||||
|
||||
// Create a new page to ensure clean state
|
||||
const newPage = await comfyPage.page.context().newPage()
|
||||
|
||||
// Set up monitoring before navigation
|
||||
await newPage.addInitScript(() => {
|
||||
// Track when various app components are ready
|
||||
|
||||
window.__appReadiness = {
|
||||
featureFlagsReceived: false,
|
||||
apiInitialized: false,
|
||||
appInitialized: false
|
||||
}
|
||||
|
||||
// Monitor when feature flags arrive by checking periodically
|
||||
const checkFeatureFlags = setInterval(() => {
|
||||
if (
|
||||
window.app?.api?.serverFeatureFlags?.value
|
||||
?.supports_preview_metadata !== undefined
|
||||
) {
|
||||
window.__appReadiness!.featureFlagsReceived = true
|
||||
clearInterval(checkFeatureFlags)
|
||||
}
|
||||
}, 10)
|
||||
|
||||
// Monitor API initialization
|
||||
const checkApi = setInterval(() => {
|
||||
if (window.app?.api) {
|
||||
window.__appReadiness!.apiInitialized = true
|
||||
clearInterval(checkApi)
|
||||
}
|
||||
}, 10)
|
||||
|
||||
// Monitor app initialization
|
||||
const checkApp = setInterval(() => {
|
||||
if (window.app?.graph) {
|
||||
window.__appReadiness!.appInitialized = true
|
||||
clearInterval(checkApp)
|
||||
}
|
||||
}, 10)
|
||||
|
||||
// Clean up after 10 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(checkFeatureFlags)
|
||||
clearInterval(checkApi)
|
||||
clearInterval(checkApp)
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
// Navigate to the app
|
||||
await newPage.goto(comfyPage.url)
|
||||
|
||||
// Wait for feature flags to be received
|
||||
await newPage.waitForFunction(
|
||||
() =>
|
||||
window.app?.api?.serverFeatureFlags?.value
|
||||
?.supports_preview_metadata !== undefined,
|
||||
{
|
||||
timeout: 10000
|
||||
}
|
||||
)
|
||||
|
||||
// Get readiness state
|
||||
const readiness = await newPage.evaluate(() => {
|
||||
return {
|
||||
...window.__appReadiness,
|
||||
currentFlags: window.app!.api.serverFeatureFlags.value
|
||||
}
|
||||
})
|
||||
|
||||
// Verify feature flags are available
|
||||
expect(readiness.currentFlags).toHaveProperty('supports_preview_metadata')
|
||||
expect(typeof readiness.currentFlags.supports_preview_metadata).toBe(
|
||||
'boolean'
|
||||
)
|
||||
expect(readiness.currentFlags).toHaveProperty('max_upload_size')
|
||||
|
||||
// Verify feature flags were received (we detected them via polling)
|
||||
expect(readiness.featureFlagsReceived).toBe(true)
|
||||
|
||||
// Verify API was initialized (feature flags require API)
|
||||
expect(readiness.apiInitialized).toBe(true)
|
||||
|
||||
await newPage.close()
|
||||
})
|
||||
|
||||
test('Backend /features endpoint returns feature flags', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Test the HTTP endpoint directly
|
||||
const response = await comfyPage.page.request.get(
|
||||
`${comfyPage.url}/api/features`
|
||||
)
|
||||
@@ -364,4 +68,27 @@ test.describe('Feature Flags', { tag: ['@slow', '@settings'] }, () => {
|
||||
expect(features).toHaveProperty('max_upload_size')
|
||||
expect(Object.keys(features).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||
const flags1 = window.app!.api.getClientFeatureFlags()
|
||||
const flags2 = window.app!.api.getClientFeatureFlags()
|
||||
|
||||
flags1.test_modification = true
|
||||
|
||||
const flags3 = window.app!.api.getClientFeatureFlags()
|
||||
|
||||
return {
|
||||
areEqual: flags1 === flags2,
|
||||
hasModification: flags3.test_modification !== undefined,
|
||||
hasSupportsPreview: flags1.supports_preview_metadata !== undefined,
|
||||
supportsPreviewValue: flags1.supports_preview_metadata
|
||||
}
|
||||
})
|
||||
|
||||
expect(immutabilityTest.areEqual).toBe(false)
|
||||
expect(immutabilityTest.hasModification).toBe(false)
|
||||
expect(immutabilityTest.hasSupportsPreview).toBe(true)
|
||||
expect(typeof immutabilityTest.supportsPreviewValue).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
8
browser_tests/types/globals.d.ts
vendored
8
browser_tests/types/globals.d.ts
vendored
@@ -14,15 +14,8 @@ export interface TestGraphAccess {
|
||||
_nodes_by_id: Record<string, LGraphNode>
|
||||
}
|
||||
|
||||
interface AppReadiness {
|
||||
featureFlagsReceived: boolean
|
||||
apiInitialized: boolean
|
||||
appInitialized: boolean
|
||||
}
|
||||
|
||||
interface CapturedMessages {
|
||||
clientFeatureFlags: unknown
|
||||
serverFeatureFlags: unknown
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -40,7 +33,6 @@ declare global {
|
||||
|
||||
// Feature flags test globals
|
||||
__capturedMessages?: CapturedMessages
|
||||
__appReadiness?: AppReadiness
|
||||
|
||||
/**
|
||||
* WebSocket store used by test fixtures for mocking WebSocket connections.
|
||||
|
||||
@@ -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,28 @@ describe('useFeatureFlags', () => {
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.supportsPreviewMetadata).toBe(true)
|
||||
expect(api.getServerFeature).toHaveBeenCalledWith(
|
||||
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
|
||||
ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA
|
||||
)
|
||||
})
|
||||
|
||||
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,13 +70,13 @@ describe('useFeatureFlags', () => {
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
expect(flags.supportsManagerV4).toBe(true)
|
||||
expect(api.getServerFeature).toHaveBeenCalledWith(
|
||||
expect(serverCapabilities.getServerCapability).toHaveBeenCalledWith(
|
||||
ServerFeatureFlag.MANAGER_SUPPORTS_V4
|
||||
)
|
||||
})
|
||||
|
||||
it('should return undefined when features are not available and no default provided', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
vi.mocked(serverCapabilities.getServerCapability).mockImplementation(
|
||||
(_path, defaultValue) => defaultValue
|
||||
)
|
||||
|
||||
@@ -93,7 +89,7 @@ describe('useFeatureFlags', () => {
|
||||
|
||||
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 +100,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 +121,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 +141,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 +155,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 +163,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 +178,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 +186,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 +197,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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
@@ -27,7 +27,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,
|
||||
@@ -36,22 +36,22 @@ 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({
|
||||
get supportsPreviewMetadata() {
|
||||
return api.getServerFeature(ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
|
||||
return getServerCapability(ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
|
||||
},
|
||||
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)
|
||||
},
|
||||
get modelUploadButtonEnabled() {
|
||||
return resolveFlag(
|
||||
@@ -107,7 +107,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() {
|
||||
@@ -118,14 +118,14 @@ export function useFeatureFlags() {
|
||||
)
|
||||
},
|
||||
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
|
||||
)
|
||||
@@ -134,7 +134,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),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { VueFire, VueFireAuth } from 'vuefire'
|
||||
import { getFirebaseConfig } from '@/config/firebase'
|
||||
import '@/lib/litegraph/public/css/litegraph.css'
|
||||
import router from '@/router'
|
||||
import { initServerCapabilities } from '@/services/serverCapabilities'
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
|
||||
import App from './App.vue'
|
||||
@@ -21,6 +22,8 @@ import App from './App.vue'
|
||||
import './assets/css/style.css'
|
||||
import { i18n } from './i18n'
|
||||
|
||||
await initServerCapabilities()
|
||||
|
||||
/**
|
||||
* CRITICAL: Load remote config FIRST for cloud builds to ensure
|
||||
* window.__CONFIG__is available for all modules during initialization
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -135,8 +135,6 @@ const zLogRawResponse = z.object({
|
||||
entries: z.array(zLogEntry)
|
||||
})
|
||||
|
||||
const zFeatureFlagsWsMessage = z.record(z.string(), z.any())
|
||||
|
||||
const zAssetDownloadWsMessage = z.object({
|
||||
task_id: z.string(),
|
||||
asset_name: z.string(),
|
||||
@@ -179,7 +177,6 @@ export type LogsWsMessage = z.infer<typeof zLogsWsMessage>
|
||||
export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
|
||||
export type NodeProgressState = z.infer<typeof zNodeProgressState>
|
||||
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
|
||||
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
|
||||
export type AssetDownloadWsMessage = z.infer<typeof zAssetDownloadWsMessage>
|
||||
export type AssetExportWsMessage = z.infer<typeof zAssetExportWsMessage>
|
||||
// End of ws messages
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { getServerCapability } from '@/services/serverCapabilities'
|
||||
|
||||
vi.mock('@/services/serverCapabilities', () => ({
|
||||
getServerCapability: vi.fn()
|
||||
}))
|
||||
|
||||
interface MockWebSocket {
|
||||
readyState: number
|
||||
@@ -17,12 +21,10 @@ describe('API Feature Flags', () => {
|
||||
const wsEventHandlers: { [key: string]: (event: unknown) => void } = {}
|
||||
|
||||
beforeEach(() => {
|
||||
// Use fake timers
|
||||
vi.useFakeTimers()
|
||||
|
||||
// Mock WebSocket
|
||||
mockWebSocket = {
|
||||
readyState: 1, // WebSocket.OPEN
|
||||
readyState: 1,
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
addEventListener: vi.fn(
|
||||
@@ -33,15 +35,10 @@ describe('API Feature Flags', () => {
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
|
||||
// Mock WebSocket constructor
|
||||
vi.stubGlobal('WebSocket', function (this: WebSocket) {
|
||||
Object.assign(this, mockWebSocket)
|
||||
})
|
||||
|
||||
// Reset API state
|
||||
api.serverFeatureFlags.value = {}
|
||||
|
||||
// Mock getClientFeatureFlags to return test feature flags
|
||||
vi.spyOn(api, 'getClientFeatureFlags').mockReturnValue({
|
||||
supports_preview_metadata: true,
|
||||
api_version: '1.0.0',
|
||||
@@ -56,13 +53,10 @@ describe('API Feature Flags', () => {
|
||||
|
||||
describe('Feature flags negotiation', () => {
|
||||
it('should send client feature flags as first message on connection', async () => {
|
||||
// Initialize API connection
|
||||
const initPromise = api.init()
|
||||
|
||||
// Simulate connection open
|
||||
wsEventHandlers['open'](new Event('open'))
|
||||
|
||||
// Check that feature flags were sent as first message
|
||||
expect(mockWebSocket.send).toHaveBeenCalledTimes(1)
|
||||
const sentMessage = JSON.parse(mockWebSocket.send.mock.calls[0][0])
|
||||
expect(sentMessage).toEqual({
|
||||
@@ -74,7 +68,6 @@ describe('API Feature Flags', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Simulate server response with status message
|
||||
wsEventHandlers['message']({
|
||||
data: JSON.stringify({
|
||||
type: 'status',
|
||||
@@ -85,185 +78,40 @@ describe('API Feature Flags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Simulate server feature flags response
|
||||
wsEventHandlers['message']({
|
||||
data: JSON.stringify({
|
||||
type: 'feature_flags',
|
||||
data: {
|
||||
supports_preview_metadata: true,
|
||||
async_execution: true,
|
||||
supported_formats: ['webp', 'jpeg', 'png'],
|
||||
api_version: '1.0.0',
|
||||
max_upload_size: 104857600,
|
||||
capabilities: ['isolated_nodes', 'dynamic_models']
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await initPromise
|
||||
|
||||
// Check that server features were stored
|
||||
expect(api.serverFeatureFlags.value).toEqual({
|
||||
supports_preview_metadata: true,
|
||||
async_execution: true,
|
||||
supported_formats: ['webp', 'jpeg', 'png'],
|
||||
api_version: '1.0.0',
|
||||
max_upload_size: 104857600,
|
||||
capabilities: ['isolated_nodes', 'dynamic_models']
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle server without feature flags support', async () => {
|
||||
// Initialize API connection
|
||||
const initPromise = api.init()
|
||||
|
||||
// Simulate connection open
|
||||
wsEventHandlers['open'](new Event('open'))
|
||||
|
||||
// Clear the send mock to reset
|
||||
mockWebSocket.send.mockClear()
|
||||
|
||||
// Simulate server response with status but no feature flags
|
||||
wsEventHandlers['message']({
|
||||
data: JSON.stringify({
|
||||
type: 'status',
|
||||
data: {
|
||||
status: { exec_info: { queue_remaining: 0 } },
|
||||
sid: 'test-sid'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Simulate some other message (not feature flags)
|
||||
wsEventHandlers['message']({
|
||||
data: JSON.stringify({
|
||||
type: 'execution_start',
|
||||
data: {}
|
||||
})
|
||||
})
|
||||
|
||||
await initPromise
|
||||
|
||||
// Server features should remain empty
|
||||
expect(api.serverFeatureFlags.value).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Feature checking methods', () => {
|
||||
beforeEach(() => {
|
||||
// Set up some test features
|
||||
api.serverFeatureFlags.value = {
|
||||
supports_preview_metadata: true,
|
||||
async_execution: false,
|
||||
capabilities: ['isolated_nodes', 'dynamic_models']
|
||||
}
|
||||
describe('Deprecated shims delegate to getServerCapability', () => {
|
||||
it('serverSupportsFeature delegates to getServerCapability', () => {
|
||||
vi.mocked(getServerCapability).mockReturnValue(true)
|
||||
expect(api.serverSupportsFeature('some_flag')).toBe(true)
|
||||
expect(getServerCapability).toHaveBeenCalledWith('some_flag')
|
||||
})
|
||||
|
||||
it('should check if server supports a boolean feature', () => {
|
||||
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
|
||||
expect(api.serverSupportsFeature('async_execution')).toBe(false)
|
||||
expect(api.serverSupportsFeature('non_existent_feature')).toBe(false)
|
||||
it('getServerFeature delegates to getServerCapability', () => {
|
||||
vi.mocked(getServerCapability).mockReturnValue(42)
|
||||
expect(api.getServerFeature('max_upload_size', 0)).toBe(42)
|
||||
expect(getServerCapability).toHaveBeenCalledWith('max_upload_size', 0)
|
||||
})
|
||||
|
||||
it('should get server feature value', () => {
|
||||
expect(api.getServerFeature('supports_preview_metadata')).toBe(true)
|
||||
expect(api.getServerFeature('capabilities')).toEqual([
|
||||
'isolated_nodes',
|
||||
'dynamic_models'
|
||||
])
|
||||
expect(api.getServerFeature('non_existent_feature')).toBeUndefined()
|
||||
it('getServerFeatures returns empty object', () => {
|
||||
expect(api.getServerFeatures()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Client feature flags configuration', () => {
|
||||
it('should use mocked client feature flags', () => {
|
||||
// Verify mocked flags are returned
|
||||
const clientFlags = api.getClientFeatureFlags()
|
||||
expect(clientFlags).toEqual({
|
||||
supports_preview_metadata: true,
|
||||
api_version: '1.0.0',
|
||||
capabilities: ['bulk_operations', 'async_nodes']
|
||||
})
|
||||
})
|
||||
|
||||
it('should return a copy of client feature flags', () => {
|
||||
// Temporarily restore the real implementation for this test
|
||||
vi.mocked(api.getClientFeatureFlags).mockRestore()
|
||||
|
||||
// Verify that modifications to returned object don't affect original
|
||||
const clientFlags1 = api.getClientFeatureFlags()
|
||||
const clientFlags2 = api.getClientFeatureFlags()
|
||||
|
||||
// Should be different objects
|
||||
expect(clientFlags1).not.toBe(clientFlags2)
|
||||
|
||||
// But with same content
|
||||
expect(clientFlags1).toEqual(clientFlags2)
|
||||
|
||||
// Modifying one should not affect the other
|
||||
clientFlags1.test_flag = true
|
||||
expect(api.getClientFeatureFlags()).not.toHaveProperty('test_flag')
|
||||
})
|
||||
})
|
||||
|
||||
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 }
|
||||
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
|
||||
|
||||
// Test without metadata support
|
||||
api.serverFeatureFlags.value = {}
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { promiseTimeout, until } from '@vueuse/core'
|
||||
import axios from 'axios'
|
||||
import { get } from 'es-toolkit/compat'
|
||||
import { trimEnd } from 'es-toolkit'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json' with { type: 'json' }
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
import { getServerCapability } from '@/services/serverCapabilities'
|
||||
import type {
|
||||
ModelFile,
|
||||
ModelFolderInfo
|
||||
@@ -36,7 +34,6 @@ import type {
|
||||
ExecutionStartWsMessage,
|
||||
ExecutionSuccessWsMessage,
|
||||
ExtensionsResponse,
|
||||
FeatureFlagsWsMessage,
|
||||
LogsRawResponse,
|
||||
LogsWsMessage,
|
||||
NotificationWsMessage,
|
||||
@@ -171,7 +168,6 @@ interface BackendApiCalls {
|
||||
}
|
||||
progress_text: ProgressTextWsMessage
|
||||
progress_state: ProgressStateWsMessage
|
||||
feature_flags: FeatureFlagsWsMessage
|
||||
asset_download: AssetDownloadWsMessage
|
||||
asset_export: AssetExportWsMessage
|
||||
}
|
||||
@@ -339,10 +335,8 @@ export class ComfyApi extends EventTarget {
|
||||
return { ...defaultClientFeatureFlags }
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature flags received from the backend server.
|
||||
*/
|
||||
serverFeatureFlags = ref<Record<string, unknown>>({})
|
||||
/** @deprecated Server capabilities are now fetched via REST before app mount. */
|
||||
serverFeatureFlags = { value: {} as Record<string, unknown> }
|
||||
|
||||
/**
|
||||
* The auth token for the comfy org account if the user is logged in.
|
||||
@@ -695,15 +689,6 @@ export class ComfyApi extends EventTarget {
|
||||
case 'notification':
|
||||
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.dispatchCustomEvent('feature_flags', msg.data)
|
||||
break
|
||||
default:
|
||||
if (this._registered.has(msg.type)) {
|
||||
// Fallback for custom types - calls super direct.
|
||||
@@ -1294,35 +1279,19 @@ 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 getServerCapability(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 getServerCapability(featureName, defaultValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {}
|
||||
}
|
||||
|
||||
async getFuseOptions(): Promise<IFuseOptions<TemplateInfo> | null> {
|
||||
|
||||
@@ -740,9 +740,7 @@ export class ComfyApp {
|
||||
releaseSharedObjectUrl(blobUrl)
|
||||
})
|
||||
|
||||
api.addEventListener('feature_flags', () => {
|
||||
void useNodeReplacementStore().load()
|
||||
})
|
||||
void useNodeReplacementStore().load()
|
||||
|
||||
api.init()
|
||||
}
|
||||
|
||||
131
src/services/serverCapabilities.test.ts
Normal file
131
src/services/serverCapabilities.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getServerCapability,
|
||||
initServerCapabilities
|
||||
} from '@/services/serverCapabilities'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
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('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()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getServerCapability', () => {
|
||||
it('returns default value when called before init', () => {
|
||||
expect(getServerCapability('some_key', 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
41
src/services/serverCapabilities.ts
Normal file
41
src/services/serverCapabilities.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { get } from 'es-toolkit/compat'
|
||||
|
||||
import { isCloud } 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
|
||||
|
||||
function getApiBase(): string {
|
||||
return isCloud ? '' : location.pathname.split('/').slice(0, -1).join('/')
|
||||
}
|
||||
|
||||
export async function initServerCapabilities(): Promise<void> {
|
||||
const url = `${getApiBase()}/api/features`
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
capabilities = Object.freeze(await res.json())
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Retry on network errors
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('Failed to fetch server capabilities after retries')
|
||||
capabilities = EMPTY
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user