merge: FE-731 (PR #12375) into m1-fe-integration

This commit is contained in:
dante01yoon
2026-05-22 09:05:25 +09:00
5 changed files with 78 additions and 188 deletions

View File

@@ -1,41 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
const mockUpdateModelsForNodeType = vi.fn()
const mockGetCategoryForNodeType = vi.fn()
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
getAssets: () => [],
isModelLoading: () => false,
getError: () => undefined,
hasAssetKey: () => false,
updateModelsForNodeType: mockUpdateModelsForNodeType
})
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
getCategoryForNodeType: mockGetCategoryForNodeType
})
}))
describe('useAssetWidgetData (desktop/isCloud=false)', () => {
it('returns empty/default values without calling stores', () => {
const nodeType = ref('CheckpointLoaderSimple')
const { category, assets, isLoading, error } = useAssetWidgetData(nodeType)
expect(category.value).toBeUndefined()
expect(assets.value).toEqual([])
expect(isLoading.value).toBe(false)
expect(error.value).toBeNull()
expect(mockUpdateModelsForNodeType).not.toHaveBeenCalled()
expect(mockGetCategoryForNodeType).not.toHaveBeenCalled()
})
})

View File

@@ -4,10 +4,6 @@ import { nextTick, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
vi.mock('@/platform/distribution/types', () => ({
isCloud: true
}))
const mockAssetsByKey = new Map<string, AssetItem[]>()
const mockLoadingByKey = new Map<string, boolean>()
const mockErrorByKey = new Map<string, Error | undefined>()
@@ -31,7 +27,7 @@ vi.mock('@/stores/modelToNodeStore', () => ({
})
}))
describe('useAssetWidgetData (cloud mode, isCloud=true)', () => {
describe('useAssetWidgetData', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAssetsByKey.clear()

View File

@@ -2,7 +2,6 @@ import { computed, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
@@ -11,8 +10,6 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
* Provides reactive asset data based on node type with automatic category detection.
* Uses store-based caching to avoid duplicate fetches across multiple instances.
*
* Cloud-only composable - returns empty data when not in cloud environment.
*
* @param nodeType - ComfyUI node type (ref, getter, or plain value). Can be undefined.
* Accepts: ref('CheckpointLoaderSimple'), () => 'CheckpointLoaderSimple', or 'CheckpointLoaderSimple'
* @returns Reactive data including category, assets, dropdown items, loading state, and errors
@@ -20,61 +17,52 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
export function useAssetWidgetData(
nodeType: MaybeRefOrGetter<string | undefined>
) {
if (isCloud) {
const assetsStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const assetsStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const category = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? modelToNodeStore.getCategoryForNodeType(resolvedType)
: undefined
})
const category = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType
? modelToNodeStore.getCategoryForNodeType(resolvedType)
: undefined
})
const assets = computed<AssetItem[]>(() => {
const resolvedType = toValue(nodeType)
return resolvedType ? (assetsStore.getAssets(resolvedType) ?? []) : []
})
const assets = computed<AssetItem[]>(() => {
const resolvedType = toValue(nodeType)
return resolvedType ? (assetsStore.getAssets(resolvedType) ?? []) : []
})
const isLoading = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType ? assetsStore.isModelLoading(resolvedType) : false
})
const isLoading = computed(() => {
const resolvedType = toValue(nodeType)
return resolvedType ? assetsStore.isModelLoading(resolvedType) : false
})
const error = computed<Error | null>(() => {
const resolvedType = toValue(nodeType)
return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null
})
const error = computed<Error | null>(() => {
const resolvedType = toValue(nodeType)
return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null
})
watch(
() => toValue(nodeType),
async (currentNodeType) => {
if (!currentNodeType) {
return
}
watch(
() => toValue(nodeType),
async (currentNodeType) => {
if (!currentNodeType) {
return
}
const isLoading = assetsStore.isModelLoading(currentNodeType)
const hasBeenInitialized = assetsStore.hasAssetKey(currentNodeType)
const isLoading = assetsStore.isModelLoading(currentNodeType)
const hasBeenInitialized = assetsStore.hasAssetKey(currentNodeType)
if (!isLoading && !hasBeenInitialized) {
await assetsStore.updateModelsForNodeType(currentNodeType)
}
},
{ immediate: true }
)
return {
category,
assets,
isLoading,
error
}
}
if (!isLoading && !hasBeenInitialized) {
await assetsStore.updateModelsForNodeType(currentNodeType)
}
},
{ immediate: true }
)
return {
category: computed(() => undefined),
assets: computed<AssetItem[]>(() => []),
isLoading: computed(() => false),
error: computed(() => null)
category,
assets,
isLoading,
error
}
}

View File

@@ -25,7 +25,6 @@ function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
}
// Use vi.hoisted() to ensure mock state is initialized before mocks
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
const mockGetAssets = vi.hoisted(() => vi.fn(() => [] as AssetItem[]))
@@ -41,12 +40,6 @@ vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockDistributionState.isCloud
}
}))
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: vi.fn(() => ({
get inputAssets() {
@@ -147,7 +140,6 @@ describe('useComboWidget', () => {
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(false)
vi.mocked(useAssetBrowserDialog).mockClear()
mockDistributionState.isCloud = false
mockAssetsStoreState.inputAssets = []
mockAssetsStoreState.inputLoading = false
mockUpdateInputs.mockClear()
@@ -174,8 +166,7 @@ describe('useComboWidget', () => {
expect(widget).toBe(mockWidget)
})
it('should create normal combo widget when asset API is disabled', () => {
mockDistributionState.isCloud = true
it('should create normal combo widget when asset browser is not eligible', () => {
mockSettingStoreGet.mockReturnValue(false)
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(false)
@@ -201,15 +192,14 @@ describe('useComboWidget', () => {
expect(widget).toBe(mockWidget)
})
describe('cloud asset browser widget', () => {
describe('asset browser widget', () => {
// "Select model" is the fallback from t('widgets.selectModel')
// in createAssetWidget when defaultValue is undefined.
const PLACEHOLDER = 'Select model'
function setupCloudAssetWidget(
function setupAssetBrowserWidget(
inputSpecOverrides: Partial<InputSpec> = {}
) {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
const constructor = useComboWidget()
@@ -238,7 +228,7 @@ describe('useComboWidget', () => {
createMockAssetItem({ name: 'cloud_model.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
const { mockNode } = setupAssetBrowserWidget({
options: ['model1.safetensors', 'model2.safetensors']
})
@@ -254,38 +244,37 @@ describe('useComboWidget', () => {
)
})
it('should use first cloud asset as default instead of server combo options', () => {
it('should use first asset as default instead of server combo options', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
createMockAssetItem({ name: 'asset_model.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
const { mockNode } = setupAssetBrowserWidget({
options: ['local_only_model.safetensors']
})
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
expect(getWidgetDefault(mockNode)).toBe('asset_model.safetensors')
})
it('should fallback to assets[0] when inputSpec.default not in cloud assets', () => {
it('should fallback to assets[0] when inputSpec.default not in assets', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'cloud_model.safetensors' })
createMockAssetItem({ name: 'asset_model.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
default: 'not_in_cloud.safetensors'
const { mockNode } = setupAssetBrowserWidget({
default: 'not_in_assets.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe('cloud_model.safetensors')
expect(getWidgetDefault(mockNode)).toBe('asset_model.safetensors')
})
it('should prefer inputSpec.default when it exists in cloud assets', () => {
it('should prefer inputSpec.default when it exists in assets', () => {
mockGetAssets.mockReturnValue([
createMockAssetItem({ name: 'other_model.safetensors' }),
createMockAssetItem({ name: 'fallback.safetensors' })
])
const { mockNode } = setupCloudAssetWidget({
// Note: no options array provided
const { mockNode } = setupAssetBrowserWidget({
default: 'fallback.safetensors'
})
@@ -295,18 +284,17 @@ describe('useComboWidget', () => {
it('should create asset browser widget when default value provided without options', () => {
mockGetAssets.mockReturnValue([])
const { mockNode } = setupCloudAssetWidget({
// Note: no options array provided
const { mockNode } = setupAssetBrowserWidget({
default: 'fallback.safetensors'
})
expect(getWidgetDefault(mockNode)).toBe(PLACEHOLDER)
})
it('should fallback to placeholder when cloud assets not loaded', () => {
it('should fallback to placeholder when assets not loaded', () => {
mockGetAssets.mockReturnValue([])
const { mockNode } = setupCloudAssetWidget({
const { mockNode } = setupAssetBrowserWidget({
options: ['local_model.safetensors']
})
@@ -315,7 +303,6 @@ describe('useComboWidget', () => {
})
it('should show Select model when asset widget has undefined current value', () => {
mockDistributionState.isCloud = true
vi.mocked(assetService.shouldUseAssetBrowser).mockReturnValue(true)
const constructor = useComboWidget()
@@ -343,7 +330,7 @@ describe('useComboWidget', () => {
expect(widget).toBe(mockWidget)
})
describe('cloud input asset mapping', () => {
describe('input asset mapping', () => {
const HASH_FILENAME =
'72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
const HASH_FILENAME_2 =
@@ -354,10 +341,8 @@ describe('useComboWidget', () => {
{ nodeClass: 'LoadVideo', inputName: 'video' },
{ nodeClass: 'LoadAudio', inputName: 'audio' }
])(
'should create combo widget with getOptionLabel for $nodeClass in cloud',
'should create combo widget with getOptionLabel for $nodeClass',
({ nodeClass, inputName }) => {
mockDistributionState.isCloud = true
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'combo',
@@ -387,9 +372,7 @@ describe('useComboWidget', () => {
}
)
it('should keep the original options object for cloud input mappings', () => {
mockDistributionState.isCloud = true
it('should keep the original options object for input mappings', () => {
const constructor = useComboWidget()
const mockNode = createMockNode('LoadImage')
const inputSpec = createMockInputSpec({
@@ -405,7 +388,6 @@ describe('useComboWidget', () => {
})
it("should format option labels using store's getInputName function", () => {
mockDistributionState.isCloud = true
mockGetInputName.mockReturnValue('Beautiful Sunset.png')
const constructor = useComboWidget()
@@ -445,9 +427,7 @@ describe('useComboWidget', () => {
expect(result).toBe('Beautiful Sunset.png')
})
it('should create normal combo widget for non-input nodes in cloud', () => {
mockDistributionState.isCloud = true
it('should create normal combo widget for non-input nodes', () => {
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('SomeOtherNode')
@@ -469,34 +449,7 @@ describe('useComboWidget', () => {
expect(widget).toBe(mockWidget)
})
it('should create normal combo widget for LoadImage in OSS', () => {
mockDistributionState.isCloud = false
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME, HASH_FILENAME_2]
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'image',
HASH_FILENAME,
expect.any(Function),
{
values: [HASH_FILENAME, HASH_FILENAME_2]
}
)
expect(widget).toBe(mockWidget)
})
it('should trigger lazy load for cloud input nodes', () => {
mockDistributionState.isCloud = true
it('should trigger lazy load for input nodes', () => {
mockAssetsStoreState.inputAssets = []
mockAssetsStoreState.inputLoading = false
@@ -515,7 +468,6 @@ describe('useComboWidget', () => {
})
it('should not trigger lazy load if assets already loading', () => {
mockDistributionState.isCloud = true
mockAssetsStoreState.inputAssets = []
mockAssetsStoreState.inputLoading = true
@@ -534,7 +486,6 @@ describe('useComboWidget', () => {
})
it('should not trigger lazy load if assets already loaded', () => {
mockDistributionState.isCloud = true
mockAssetsStoreState.inputAssets = [
createMockAssetItem({
id: 'asset-123',

View File

@@ -8,7 +8,6 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
import { isCloud } from '@/platform/distribution/types'
import type {
ComboInputSpec,
InputSpec
@@ -106,11 +105,11 @@ const addMultiSelectWidget = (
}
/**
* Resolve the default value for a cloud asset widget.
* Priority: inputSpec.default (if present in cloud assets) → first cloud
* asset → undefined (shows placeholder).
* Resolve the default value for an asset widget.
* Priority: inputSpec.default (if present in assets) → first asset → undefined
* (shows placeholder).
*/
function resolveCloudDefault(
function resolveAssetDefault(
nodeType: string,
specDefault: string | undefined
): string | undefined {
@@ -119,7 +118,6 @@ function resolveCloudDefault(
const inAssets = assets.some((a) => getAssetFilename(a) === specDefault)
if (inAssets) return specDefault
}
// empty filename → undefined (shows placeholder)
const filename = assets.length ? getAssetFilename(assets[0]) : undefined
return filename || undefined
}
@@ -213,21 +211,19 @@ const addComboWidget = (
): IBaseWidget => {
const defaultValue = getDefaultValue(inputSpec)
if (isCloud) {
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
// Default from cloud assets, not from server combo options.
// Server options list local files that may not exist in the user's
// cloud asset library, leading to missing-model errors on undo/reload.
const cloudDefault = resolveCloudDefault(
node.comfyClass ?? '',
inputSpec.default
)
return createAssetBrowserWidget(node, inputSpec, cloudDefault)
}
if (assetService.shouldUseAssetBrowser(node.comfyClass, inputSpec.name)) {
// Default from asset library, not from server combo options.
// Server options list local files that may not exist in the user's
// asset library, leading to missing-model errors on undo/reload.
const assetDefault = resolveAssetDefault(
node.comfyClass ?? '',
inputSpec.default
)
return createAssetBrowserWidget(node, inputSpec, assetDefault)
}
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
return createInputMappingWidget(node, inputSpec, defaultValue)
}
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
return createInputMappingWidget(node, inputSpec, defaultValue)
}
// Standard combo widget