diff --git a/src/platform/assets/services/uploadService.test.ts b/src/platform/assets/services/uploadService.test.ts index b3e523d25..6e86cce62 100644 --- a/src/platform/assets/services/uploadService.test.ts +++ b/src/platform/assets/services/uploadService.test.ts @@ -10,6 +10,17 @@ vi.mock('@/scripts/api', () => ({ } })) +function createMockResponse( + status: number, + data?: { name: string; subfolder?: string } +) { + return { + status, + statusText: status === 200 ? 'OK' : 'Error', + json: vi.fn().mockResolvedValue(data ?? {}) + } as unknown as Response +} + describe('uploadService', () => { beforeEach(() => { vi.clearAllMocks() @@ -18,16 +29,8 @@ describe('uploadService', () => { describe('uploadMedia', () => { it('uploads File successfully', async () => { const mockFile = new File(['content'], 'test.png', { type: 'image/png' }) - const mockResponse = { - status: 200, - json: vi.fn().mockResolvedValue({ - name: 'test.png', - subfolder: 'uploads' - }) - } - vi.mocked(api.fetchApi).mockResolvedValue( - mockResponse as Partial as Response + createMockResponse(200, { name: 'test.png', subfolder: 'uploads' }) ) const result = await uploadMedia({ source: mockFile }) @@ -40,16 +43,8 @@ describe('uploadService', () => { it('uploads Blob successfully', async () => { const mockBlob = new Blob(['content'], { type: 'image/png' }) - const mockResponse = { - status: 200, - json: vi.fn().mockResolvedValue({ - name: 'upload-123.png', - subfolder: '' - }) - } - vi.mocked(api.fetchApi).mockResolvedValue( - mockResponse as Partial as Response + createMockResponse(200, { name: 'upload-123.png', subfolder: '' }) ) const result = await uploadMedia({ source: mockBlob }) @@ -64,16 +59,8 @@ describe('uploadService', () => { blob: () => Promise.resolve(new Blob(['content'])) } as Response) - const mockResponse = { - status: 200, - json: vi.fn().mockResolvedValue({ - name: 'upload-456.png', - subfolder: '' - }) - } - vi.mocked(api.fetchApi).mockResolvedValue( - mockResponse as Partial as Response + createMockResponse(200, { name: 'upload-456.png', subfolder: '' }) ) try { @@ -95,13 +82,8 @@ describe('uploadService', () => { it('includes subfolder in FormData', async () => { const mockFile = new File(['content'], 'test.png') - const mockResponse = { - status: 200, - json: vi.fn().mockResolvedValue({ name: 'test.png' }) - } - vi.mocked(api.fetchApi).mockResolvedValue( - mockResponse as Partial as Response + createMockResponse(200, { name: 'test.png' }) ) await uploadMedia( @@ -134,14 +116,10 @@ describe('uploadService', () => { it('handles upload errors', async () => { const mockFile = new File(['content'], 'test.png') - const mockResponse = { + vi.mocked(api.fetchApi).mockResolvedValue({ status: 500, statusText: 'Internal Server Error' - } - - vi.mocked(api.fetchApi).mockResolvedValue( - mockResponse as Partial as Response - ) + } as unknown as Response) const result = await uploadMedia({ source: mockFile }) @@ -162,13 +140,8 @@ describe('uploadService', () => { it('includes originalRef for mask uploads', async () => { const mockFile = new File(['content'], 'mask.png') - const mockResponse = { - status: 200, - json: vi.fn().mockResolvedValue({ name: 'mask.png' }) - } - vi.mocked(api.fetchApi).mockResolvedValue( - mockResponse as Partial as Response + createMockResponse(200, { name: 'mask.png' }) ) const originalRef = { @@ -189,25 +162,26 @@ describe('uploadService', () => { }) describe('uploadMediaBatch', () => { + it('returns empty array for empty input', async () => { + const results = await uploadMediaBatch([]) + + expect(results).toHaveLength(0) + expect(api.fetchApi).not.toHaveBeenCalled() + }) + it('uploads multiple files', async () => { const mockFiles = [ new File(['1'], 'file1.png'), new File(['2'], 'file2.png') ] - const mockResponse1 = { - status: 200, - json: vi.fn().mockResolvedValue({ name: 'file1.png', subfolder: '' }) - } - - const mockResponse2 = { - status: 200, - json: vi.fn().mockResolvedValue({ name: 'file2.png', subfolder: '' }) - } - vi.mocked(api.fetchApi) - .mockResolvedValueOnce(mockResponse1 as Partial as Response) - .mockResolvedValueOnce(mockResponse2 as Partial as Response) + .mockResolvedValueOnce( + createMockResponse(200, { name: 'file1.png', subfolder: '' }) + ) + .mockResolvedValueOnce( + createMockResponse(200, { name: 'file2.png', subfolder: '' }) + ) const results = await uploadMediaBatch( mockFiles.map((source) => ({ source })) diff --git a/src/platform/assets/services/uploadService.ts b/src/platform/assets/services/uploadService.ts index 7b020c7db..2724050e5 100644 --- a/src/platform/assets/services/uploadService.ts +++ b/src/platform/assets/services/uploadService.ts @@ -46,7 +46,7 @@ async function convertToFile( if (source instanceof Blob) { const name = filename || `upload-${Date.now()}.png` - return new File([source], name, { type: mimeType }) + return new File([source], name, { type: source.type || mimeType }) } // dataURL string diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index bca20feab..44cedcdb5 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -38,7 +38,15 @@ import { filterWidgetProps } from '@/utils/widgetPropFilter' -interface Props { +const { + widget, + nodeType, + assetKind, + allowUpload = false, + uploadFolder, + isAssetMode = false, + defaultLayoutMode = 'grid' +} = defineProps<{ widget: SimplifiedWidget nodeType?: string assetKind?: AssetKind @@ -46,18 +54,16 @@ interface Props { uploadFolder?: ResultItemType isAssetMode?: boolean defaultLayoutMode?: LayoutMode -} - -const props = defineProps() +}>() provide( AssetKindKey, - computed(() => props.assetKind) + computed(() => assetKind) ) const modelValue = defineModel({ - default(props: Props) { - return props.widget.options?.values?.[0] || '' + default() { + return widget.options?.values?.[0] || '' } }) @@ -68,14 +74,14 @@ const queueStore = useQueueStore() const transformCompatProps = useTransformCompatOverlayProps() const combinedProps = computed(() => ({ - ...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS), + ...filterWidgetProps(widget.options, PANEL_EXCLUDED_PROPS), ...transformCompatProps.value })) const getAssetData = () => { - const nodeType = props.widget.options?.nodeType ?? props.nodeType - if (props.isAssetMode && nodeType) { - return useAssetWidgetData(toRef(nodeType)) + const resolvedNodeType = widget.options?.nodeType ?? nodeType + if (isAssetMode && resolvedNodeType) { + return useAssetWidgetData(toRef(resolvedNodeType)) } return null } @@ -83,7 +89,7 @@ const assetData = getAssetData() const filterSelected = ref('all') const filterOptions = computed(() => { - if (props.isAssetMode) { + if (isAssetMode) { const categoryName = assetData?.category.value ?? 'All' return [{ name: capitalize(categoryName), value: 'all' }] } @@ -95,28 +101,23 @@ const filterOptions = computed(() => { }) const ownershipSelected = ref('all') -const showOwnershipFilter = computed(() => props.isAssetMode) +const showOwnershipFilter = computed(() => isAssetMode) const { ownershipOptions, availableBaseModels } = useAssetFilterOptions( () => assetData?.assets.value ?? [] ) const baseModelSelected = ref>(new Set()) -const showBaseModelFilter = computed(() => props.isAssetMode) +const showBaseModelFilter = computed(() => isAssetMode) const baseModelOptions = computed(() => { - if (!props.isAssetMode || !assetData) return [] + if (!isAssetMode || !assetData) return [] return availableBaseModels.value }) const selectedSet = ref>(new Set()) -/** - * Transforms a value using getOptionLabel if available. - * Falls back to the original value if getOptionLabel is not provided, - * returns undefined/null, or throws an error. - */ function getDisplayLabel(value: string): string { - const getOptionLabel = props.widget.options?.getOptionLabel + const getOptionLabel = widget.options?.getOptionLabel if (!getOptionLabel) return value try { @@ -128,7 +129,7 @@ function getDisplayLabel(value: string): string { } const inputItems = computed(() => { - const values = props.widget.options?.values || [] + const values = widget.options?.values || [] if (!Array.isArray(values)) { return [] @@ -142,7 +143,7 @@ const inputItems = computed(() => { })) }) const outputItems = computed(() => { - if (!['image', 'video'].includes(props.assetKind ?? '')) return [] + if (!['image', 'video'].includes(assetKind ?? '')) return [] const outputs = new Set() @@ -150,8 +151,8 @@ const outputItems = computed(() => { queueStore.historyTasks.forEach((task) => { task.flatOutputs.forEach((output) => { const isTargetType = - (props.assetKind === 'image' && output.mediaType === 'images') || - (props.assetKind === 'video' && output.mediaType === 'video') + (assetKind === 'image' && output.mediaType === 'images') || + (assetKind === 'video' && output.mediaType === 'video') if (output.type === 'output' && isTargetType) { const path = output.subfolder @@ -172,18 +173,11 @@ const outputItems = computed(() => { })) }) -/** - * Creates a fallback item for the current modelValue when it doesn't exist - * in the available items list. This handles cases like template-loaded nodes - * where the saved value may not exist in the current server environment. - * Works for both local mode (inputItems/outputItems) and cloud mode (assetData). - */ const missingValueItem = computed(() => { const currentValue = modelValue.value if (!currentValue) return undefined - // Check in cloud mode assets - if (props.isAssetMode && assetData) { + if (isAssetMode && assetData) { const existsInAssets = assetData.assets.value.some( (asset) => getAssetFilename(asset) === currentValue ) @@ -197,7 +191,6 @@ const missingValueItem = computed(() => { } } - // Check in local mode inputs/outputs const existsInInputs = inputItems.value.some( (item) => item.name === currentValue ) @@ -220,12 +213,8 @@ const missingValueItem = computed(() => { } }) -/** - * Transforms AssetItem[] to FormDropdownItem[] for cloud mode. - * Uses getAssetFilename for display name, asset.name for label. - */ const assetItems = computed(() => { - if (!props.isAssetMode || !assetData) return [] + if (!isAssetMode || !assetData) return [] return assetData.assets.value.map((asset) => ({ id: asset.id, name: getAssetFilename(asset), @@ -248,7 +237,7 @@ const baseModelFilteredAssetItems = computed(() => ) const allItems = computed(() => { - if (props.isAssetMode && assetData) { + if (isAssetMode && assetData) { if (missingValueItem.value) { return [missingValueItem.value, ...baseModelFilteredAssetItems.value] } @@ -262,7 +251,7 @@ const allItems = computed(() => { }) const dropdownItems = computed(() => { - if (props.isAssetMode) { + if (isAssetMode) { return allItems.value } @@ -278,13 +267,13 @@ const dropdownItems = computed(() => { }) const mediaPlaceholder = computed(() => { - const options = props.widget.options + const options = widget.options if (options?.placeholder) { return options.placeholder } - switch (props.assetKind) { + switch (assetKind) { case 'image': return t('widgets.uploadSelect.placeholderImage') case 'video': @@ -301,14 +290,12 @@ const mediaPlaceholder = computed(() => { }) const uploadable = computed(() => { - if (props.isAssetMode) return false - return props.allowUpload === true + if (isAssetMode) return false + return allowUpload }) const acceptTypes = computed(() => { - // Be permissive with accept types because backend uses libraries - // that can handle a wide range of formats - switch (props.assetKind) { + switch (assetKind) { case 'image': return 'image/*' case 'video': @@ -320,7 +307,7 @@ const acceptTypes = computed(() => { } }) -const layoutMode = ref(props.defaultLayoutMode ?? 'grid') +const layoutMode = ref(defaultLayoutMode) watch( [modelValue, dropdownItems], @@ -357,7 +344,7 @@ function updateSelectedItems(selectedItems: Set) { } const uploadFiles = async (files: File[]): Promise => { - const folder = props.uploadFolder ?? 'input' + const folder = uploadFolder ?? 'input' const assetsStore = useAssetsStore() const results = await uploadMediaBatch( @@ -383,17 +370,13 @@ async function handleFilesUpdate(files: File[]) { if (!files || files.length === 0) return try { - // 1. Upload files to server const uploadedPaths = await uploadFiles(files) if (uploadedPaths.length === 0) { - toastStore.addAlert(t('toastMessages.uploadFailed')) return } - // 2. Update widget options to include new files - // This simulates what addToComboValues does but for SimplifiedWidget - const values = props.widget.options?.values + const values = widget.options?.values if (Array.isArray(values)) { uploadedPaths.forEach((path) => { if (!values.includes(path)) { @@ -402,12 +385,10 @@ async function handleFilesUpdate(files: File[]) { }) } - // 3. Update widget value to the first uploaded file modelValue.value = uploadedPaths[0] - // 4. Trigger callback to notify underlying LiteGraph widget - if (props.widget.callback) { - props.widget.callback(uploadedPaths[0]) + if (widget.callback) { + widget.callback(uploadedPaths[0]) } } catch (error) { console.error('Upload error:', error) @@ -419,7 +400,7 @@ function getMediaUrl( filename: string, type: 'input' | 'output' = 'input' ): string { - if (!['image', 'video'].includes(props.assetKind ?? '')) return '' + if (!['image', 'video'].includes(assetKind ?? '')) return '' return `/api/view?filename=${encodeURIComponent(filename)}&type=${type}` }