mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 10:42:44 +00:00
Full Asset Selection Experience (Assets API) (#5900)
## Summary Full Integration of Asset Browsing and Selection when Assets API is enabled. ## Changes 1. Replace Model Left Side Tab with experience 2. Configurable titles for the Asset Browser Modal 3. Refactors to simplify callback code 4. Refactor to make modal filters reactive (they change their values based on assets displayed) 5. Add `browse()` mode with ability to create node directly from the Asset Browser Modal (in `browse()` mode) ## Screenshots Demo of many different types of Nodes getting configured by the Modal https://github.com/user-attachments/assets/34f9c964-cdf2-4c5d-86a9-a8e7126a7de9 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5900-Feat-asset-selection-cloud-integration-2816d73d365081ccb4aeecdc14b0e5d3) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -38,6 +38,7 @@ import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
|||||||
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
|
||||||
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||||
import { useUserStore } from '@/stores/userStore'
|
import { useUserStore } from '@/stores/userStore'
|
||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
@@ -51,6 +52,7 @@ import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
|
|||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const commandStore = useCommandStore()
|
||||||
|
|
||||||
const teleportTarget = computed(() =>
|
const teleportTarget = computed(() =>
|
||||||
settingStore.get('Comfy.Sidebar.Location') === 'left'
|
settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||||
@@ -64,9 +66,12 @@ const isSmall = computed(
|
|||||||
|
|
||||||
const tabs = computed(() => workspaceStore.getSidebarTabs())
|
const tabs = computed(() => workspaceStore.getSidebarTabs())
|
||||||
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||||
const onTabClick = (item: SidebarTabExtension) => {
|
|
||||||
workspaceStore.sidebarTab.toggleSidebarTab(item.id)
|
const onTabClick = async (item: SidebarTabExtension) =>
|
||||||
}
|
await commandStore.commands
|
||||||
|
.find((cmd) => cmd.id === `Workspace.ToggleSidebarTab.${item.id}`)
|
||||||
|
?.function?.()
|
||||||
|
|
||||||
const keybindingStore = useKeybindingStore()
|
const keybindingStore = useKeybindingStore()
|
||||||
const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
|
const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
|
||||||
const keybinding = keybindingStore.getKeybindingByCommandId(
|
const keybinding = keybindingStore.getKeybindingByCommandId(
|
||||||
|
|||||||
@@ -48,7 +48,10 @@
|
|||||||
<main class="flex flex-col flex-1 min-h-0">
|
<main class="flex flex-col flex-1 min-h-0">
|
||||||
<!-- Fallback title bar when no leftPanel is provided -->
|
<!-- Fallback title bar when no leftPanel is provided -->
|
||||||
<slot name="contentFilter"></slot>
|
<slot name="contentFilter"></slot>
|
||||||
<h2 v-if="!$slots.leftPanel" class="text-xxl px-6 pt-2 pb-6 m-0">
|
<h2
|
||||||
|
v-if="!$slots.leftPanel"
|
||||||
|
class="text-xxl px-6 pt-2 pb-6 m-0 capitalize"
|
||||||
|
>
|
||||||
{{ contentTitle }}
|
{{ contentTitle }}
|
||||||
</h2>
|
</h2>
|
||||||
<div :class="contentContainerClasses">
|
<div :class="contentContainerClasses">
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
SubgraphNode
|
SubgraphNode
|
||||||
} from '@/lib/litegraph/src/litegraph'
|
} from '@/lib/litegraph/src/litegraph'
|
||||||
import type { Point } from '@/lib/litegraph/src/litegraph'
|
import type { Point } from '@/lib/litegraph/src/litegraph'
|
||||||
|
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||||
|
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||||
@@ -1062,6 +1064,30 @@ export function useCoreCommands(): ComfyCommand[] {
|
|||||||
}
|
}
|
||||||
await api.freeMemory({ freeExecutionCache: true })
|
await api.freeMemory({ freeExecutionCache: true })
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Comfy.BrowseModelAssets',
|
||||||
|
icon: 'pi pi-folder-open',
|
||||||
|
label: 'Browse Model Assets',
|
||||||
|
versionAdded: '1.28.3',
|
||||||
|
function: async () => {
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
await assetBrowserDialog.browse({
|
||||||
|
assetType: 'models',
|
||||||
|
title: t('sideToolbar.modelLibrary'),
|
||||||
|
onAssetSelected: (asset) => {
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
if (!result.success) {
|
||||||
|
toastStore.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('g.error'),
|
||||||
|
detail: t('assetBrowser.failedToCreateNode')
|
||||||
|
})
|
||||||
|
console.error('Node creation failed:', result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1942,9 +1942,11 @@
|
|||||||
"tryAdjustingFilters": "Try adjusting your search or filters",
|
"tryAdjustingFilters": "Try adjusting your search or filters",
|
||||||
"loadingModels": "Loading {type}...",
|
"loadingModels": "Loading {type}...",
|
||||||
"connectionError": "Please check your connection and try again",
|
"connectionError": "Please check your connection and try again",
|
||||||
|
"failedToCreateNode": "Failed to create node. Please try again or check console for details.",
|
||||||
"noModelsInFolder": "No {type} available in this folder",
|
"noModelsInFolder": "No {type} available in this folder",
|
||||||
"searchAssetsPlaceholder": "Search assets...",
|
"searchAssetsPlaceholder": "Search assets...",
|
||||||
"allModels": "All Models",
|
"allModels": "All Models",
|
||||||
|
"allCategory": "All {category}",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"fileFormats": "File formats",
|
"fileFormats": "File formats",
|
||||||
"baseModels": "Base models",
|
"baseModels": "Base models",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<BaseModalLayout
|
<BaseModalLayout
|
||||||
data-component-id="AssetBrowserModal"
|
data-component-id="AssetBrowserModal"
|
||||||
class="size-full max-h-full max-w-full min-w-0"
|
class="size-full max-h-full max-w-full min-w-0"
|
||||||
:content-title="contentTitle"
|
:content-title="displayTitle"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
>
|
>
|
||||||
<template v-if="shouldShowLeftPanel" #leftPanel>
|
<template v-if="shouldShowLeftPanel" #leftPanel>
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
<template #header-icon>
|
<template #header-icon>
|
||||||
<div class="icon-[lucide--folder] size-4" />
|
<div class="icon-[lucide--folder] size-4" />
|
||||||
</template>
|
</template>
|
||||||
<template #header-title>{{ $t('assetBrowser.browseAssets') }}</template>
|
<template #header-title>
|
||||||
|
<span class="capitalize">{{ displayTitle }}</span>
|
||||||
|
</template>
|
||||||
</LeftSidePanel>
|
</LeftSidePanel>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -28,7 +30,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #contentFilter>
|
<template #contentFilter>
|
||||||
<AssetFilterBar :assets="assets" @filter-change="updateFilters" />
|
<AssetFilterBar
|
||||||
|
:assets="categoryFilteredAssets"
|
||||||
|
@filter-change="updateFilters"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<template #content>
|
||||||
@@ -56,10 +61,11 @@ import { OnCloseKey } from '@/types/widgetTypes'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
nodeType?: string
|
nodeType?: string
|
||||||
inputName?: string
|
inputName?: string
|
||||||
onSelect?: (assetPath: string) => void
|
onSelect?: (asset: AssetItem) => void
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
showLeftPanel?: boolean
|
showLeftPanel?: boolean
|
||||||
assets?: AssetItem[]
|
assets?: AssetItem[]
|
||||||
|
title?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -74,11 +80,15 @@ const {
|
|||||||
selectedCategory,
|
selectedCategory,
|
||||||
availableCategories,
|
availableCategories,
|
||||||
contentTitle,
|
contentTitle,
|
||||||
|
categoryFilteredAssets,
|
||||||
filteredAssets,
|
filteredAssets,
|
||||||
selectAssetWithCallback,
|
|
||||||
updateFilters
|
updateFilters
|
||||||
} = useAssetBrowser(props.assets)
|
} = useAssetBrowser(props.assets)
|
||||||
|
|
||||||
|
const displayTitle = computed(() => {
|
||||||
|
return props.title ?? contentTitle.value
|
||||||
|
})
|
||||||
|
|
||||||
const shouldShowLeftPanel = computed(() => {
|
const shouldShowLeftPanel = computed(() => {
|
||||||
return props.showLeftPanel ?? true
|
return props.showLeftPanel ?? true
|
||||||
})
|
})
|
||||||
@@ -88,8 +98,10 @@ function handleClose() {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
|
function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
|
||||||
emit('asset-select', asset)
|
emit('asset-select', asset)
|
||||||
await selectAssetWithCallback(asset.id, props.onSelect)
|
// onSelect callback is provided by dialog composable layer
|
||||||
|
// It handles the appropriate transformation (filename extraction or full asset)
|
||||||
|
props.onSelect?.(asset)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createAssetWithSpecificBaseModel,
|
createAssetWithSpecificBaseModel,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
createAssetWithoutBaseModel,
|
createAssetWithoutBaseModel,
|
||||||
createAssetWithoutExtension
|
createAssetWithoutExtension
|
||||||
} from '@/platform/assets/fixtures/ui-mock-assets'
|
} from '@/platform/assets/fixtures/ui-mock-assets'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
import AssetFilterBar from './AssetFilterBar.vue'
|
import AssetFilterBar from './AssetFilterBar.vue'
|
||||||
|
|
||||||
@@ -46,14 +48,19 @@ export default meta
|
|||||||
type Story = StoryObj<typeof meta>
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
export const BothFiltersVisible: Story = {
|
export const BothFiltersVisible: Story = {
|
||||||
args: {
|
render: () => ({
|
||||||
assets: [
|
components: { AssetFilterBar },
|
||||||
createAssetWithSpecificExtension('safetensors'),
|
setup() {
|
||||||
createAssetWithSpecificExtension('ckpt'),
|
const assets = ref<AssetItem[]>([
|
||||||
createAssetWithSpecificBaseModel('sd15'),
|
createAssetWithSpecificExtension('safetensors'),
|
||||||
createAssetWithSpecificBaseModel('sdxl')
|
createAssetWithSpecificExtension('ckpt'),
|
||||||
]
|
createAssetWithSpecificBaseModel('sd15'),
|
||||||
},
|
createAssetWithSpecificBaseModel('sdxl')
|
||||||
|
])
|
||||||
|
return { assets }
|
||||||
|
},
|
||||||
|
template: '<AssetFilterBar :assets="assets" />'
|
||||||
|
}),
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
@@ -65,16 +72,23 @@ export const BothFiltersVisible: Story = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const OnlyFileFormatFilter: Story = {
|
export const OnlyFileFormatFilter: Story = {
|
||||||
args: {
|
render: () => ({
|
||||||
assets: [
|
components: { AssetFilterBar },
|
||||||
// Assets with extensions but explicitly NO base models
|
setup() {
|
||||||
{
|
const assets = ref<AssetItem[]>([
|
||||||
...createAssetWithSpecificExtension('safetensors'),
|
{
|
||||||
user_metadata: undefined
|
...createAssetWithSpecificExtension('safetensors'),
|
||||||
},
|
user_metadata: undefined
|
||||||
{ ...createAssetWithSpecificExtension('ckpt'), user_metadata: undefined }
|
},
|
||||||
]
|
{
|
||||||
},
|
...createAssetWithSpecificExtension('ckpt'),
|
||||||
|
user_metadata: undefined
|
||||||
|
}
|
||||||
|
])
|
||||||
|
return { assets }
|
||||||
|
},
|
||||||
|
template: '<AssetFilterBar :assets="assets" />'
|
||||||
|
}),
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
@@ -86,16 +100,20 @@ export const OnlyFileFormatFilter: Story = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const OnlyBaseModelFilter: Story = {
|
export const OnlyBaseModelFilter: Story = {
|
||||||
args: {
|
render: () => ({
|
||||||
assets: [
|
components: { AssetFilterBar },
|
||||||
// Assets with base models but no recognizable extensions
|
setup() {
|
||||||
{
|
const assets = ref<AssetItem[]>([
|
||||||
...createAssetWithSpecificBaseModel('sd15'),
|
{
|
||||||
name: 'model_without_extension'
|
...createAssetWithSpecificBaseModel('sd15'),
|
||||||
},
|
name: 'model_without_extension'
|
||||||
{ ...createAssetWithSpecificBaseModel('sdxl'), name: 'another_model' }
|
},
|
||||||
]
|
{ ...createAssetWithSpecificBaseModel('sdxl'), name: 'another_model' }
|
||||||
},
|
])
|
||||||
|
return { assets }
|
||||||
|
},
|
||||||
|
template: '<AssetFilterBar :assets="assets" />'
|
||||||
|
}),
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
@@ -107,9 +125,14 @@ export const OnlyBaseModelFilter: Story = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const NoFiltersVisible: Story = {
|
export const NoFiltersVisible: Story = {
|
||||||
args: {
|
render: () => ({
|
||||||
assets: []
|
components: { AssetFilterBar },
|
||||||
},
|
setup() {
|
||||||
|
const assets = ref<AssetItem[]>([])
|
||||||
|
return { assets }
|
||||||
|
},
|
||||||
|
template: '<AssetFilterBar :assets="assets" />'
|
||||||
|
}),
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
@@ -121,9 +144,17 @@ export const NoFiltersVisible: Story = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const NoFiltersFromAssetsWithoutOptions: Story = {
|
export const NoFiltersFromAssetsWithoutOptions: Story = {
|
||||||
args: {
|
render: () => ({
|
||||||
assets: [createAssetWithoutExtension(), createAssetWithoutBaseModel()]
|
components: { AssetFilterBar },
|
||||||
},
|
setup() {
|
||||||
|
const assets = ref<AssetItem[]>([
|
||||||
|
createAssetWithoutExtension(),
|
||||||
|
createAssetWithoutBaseModel()
|
||||||
|
])
|
||||||
|
return { assets }
|
||||||
|
},
|
||||||
|
template: '<AssetFilterBar :assets="assets" />'
|
||||||
|
}),
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
@@ -133,3 +164,102 @@ export const NoFiltersFromAssetsWithoutOptions: Story = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CategorySwitchingReactivity: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { AssetFilterBar },
|
||||||
|
setup() {
|
||||||
|
const selectedCategory = ref('all')
|
||||||
|
|
||||||
|
const checkpointAssets: AssetItem[] = [
|
||||||
|
{
|
||||||
|
...createAssetWithSpecificExtension('safetensors'),
|
||||||
|
tags: ['models', 'checkpoints'],
|
||||||
|
user_metadata: { base_model: 'sd15' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createAssetWithSpecificExtension('safetensors'),
|
||||||
|
tags: ['models', 'checkpoints'],
|
||||||
|
user_metadata: { base_model: 'sdxl' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const loraAssets: AssetItem[] = [
|
||||||
|
{
|
||||||
|
...createAssetWithSpecificExtension('pt'),
|
||||||
|
tags: ['models', 'loras'],
|
||||||
|
user_metadata: { base_model: 'sd15' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...createAssetWithSpecificExtension('pt'),
|
||||||
|
tags: ['models', 'loras'],
|
||||||
|
user_metadata: undefined
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const allAssets = [...checkpointAssets, ...loraAssets]
|
||||||
|
|
||||||
|
const categoryFilteredAssets = ref<AssetItem[]>(allAssets)
|
||||||
|
|
||||||
|
const switchCategory = (category: string) => {
|
||||||
|
selectedCategory.value = category
|
||||||
|
categoryFilteredAssets.value =
|
||||||
|
category === 'all'
|
||||||
|
? allAssets
|
||||||
|
: category === 'checkpoints'
|
||||||
|
? checkpointAssets
|
||||||
|
: loraAssets
|
||||||
|
}
|
||||||
|
|
||||||
|
return { categoryFilteredAssets, selectedCategory, switchCategory }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
@click="switchCategory('all')"
|
||||||
|
:class="[
|
||||||
|
'px-4 py-2 rounded border',
|
||||||
|
selectedCategory === 'all'
|
||||||
|
? 'bg-blue-500 text-white border-blue-600'
|
||||||
|
: 'bg-white dark-theme:bg-charcoal-700 border-gray-300 dark-theme:border-charcoal-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
All (.safetensors + .pt, sd15 + sdxl)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="switchCategory('checkpoints')"
|
||||||
|
:class="[
|
||||||
|
'px-4 py-2 rounded border',
|
||||||
|
selectedCategory === 'checkpoints'
|
||||||
|
? 'bg-blue-500 text-white border-blue-600'
|
||||||
|
: 'bg-white dark-theme:bg-charcoal-700 border-gray-300 dark-theme:border-charcoal-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
Checkpoints (.safetensors, sd15 + sdxl)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="switchCategory('loras')"
|
||||||
|
:class="[
|
||||||
|
'px-4 py-2 rounded border',
|
||||||
|
selectedCategory === 'loras'
|
||||||
|
? 'bg-blue-500 text-white border-blue-600'
|
||||||
|
: 'bg-white dark-theme:bg-charcoal-700 border-gray-300 dark-theme:border-charcoal-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
LoRAs (.pt, sd15 only)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<AssetFilterBar :assets="categoryFilteredAssets" />
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
'Interactive demo showing filter options updating reactively when category changes. Click buttons to see filters adapt to the selected category.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,13 +67,10 @@ const sortBy = ref('name-asc')
|
|||||||
const { availableFileFormats, availableBaseModels } =
|
const { availableFileFormats, availableBaseModels } =
|
||||||
useAssetFilterOptions(assets)
|
useAssetFilterOptions(assets)
|
||||||
|
|
||||||
// TODO: Make sortOptions configurable via props
|
|
||||||
// Different asset types might need different sorting options
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
|
{ name: t('assetBrowser.sortAZ'), value: 'name-asc' },
|
||||||
{ name: t('assetBrowser.sortZA'), value: 'name-desc' },
|
{ name: t('assetBrowser.sortZA'), value: 'name-desc' },
|
||||||
{ name: t('assetBrowser.sortRecent'), value: 'recent' },
|
{ name: t('assetBrowser.sortRecent'), value: 'recent' }
|
||||||
{ name: t('assetBrowser.sortPopular'), value: 'popular' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { computed, ref } from 'vue'
|
|||||||
import { d, t } from '@/i18n'
|
import { d, t } from '@/i18n'
|
||||||
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { assetFilenameSchema } from '@/platform/assets/schemas/assetSchema'
|
|
||||||
import { assetService } from '@/platform/assets/services/assetService'
|
|
||||||
import {
|
import {
|
||||||
getAssetBaseModel,
|
getAssetBaseModel,
|
||||||
getAssetDescription
|
getAssetDescription
|
||||||
@@ -161,9 +159,13 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
|||||||
return category?.label || t('assetBrowser.assets')
|
return category?.label || t('assetBrowser.assets')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Category-filtered assets for filter options (before search/format/base model filters)
|
||||||
|
const categoryFilteredAssets = computed(() => {
|
||||||
|
return assets.filter(filterByCategory(selectedCategory.value))
|
||||||
|
})
|
||||||
|
|
||||||
const filteredAssets = computed(() => {
|
const filteredAssets = computed(() => {
|
||||||
const filtered = assets
|
const filtered = categoryFilteredAssets.value
|
||||||
.filter(filterByCategory(selectedCategory.value))
|
|
||||||
.filter(filterByQuery(searchQuery.value))
|
.filter(filterByQuery(searchQuery.value))
|
||||||
.filter(filterByFileFormats(filters.value.fileFormats))
|
.filter(filterByFileFormats(filters.value.fileFormats))
|
||||||
.filter(filterByBaseModels(filters.value.baseModels))
|
.filter(filterByBaseModels(filters.value.baseModels))
|
||||||
@@ -189,39 +191,6 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
|||||||
return filtered.map(transformAssetForDisplay)
|
return filtered.map(transformAssetForDisplay)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Asset selection that fetches full details and executes callback with filename
|
|
||||||
* @param assetId - The asset ID to select and fetch details for
|
|
||||||
* @param onSelect - Optional callback to execute with the asset filename
|
|
||||||
*/
|
|
||||||
async function selectAssetWithCallback(
|
|
||||||
assetId: string,
|
|
||||||
onSelect?: (filename: string) => void
|
|
||||||
): Promise<void> {
|
|
||||||
if (!onSelect) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const detailAsset = await assetService.getAssetDetails(assetId)
|
|
||||||
const filename = detailAsset.user_metadata?.filename
|
|
||||||
const validatedFilename = assetFilenameSchema.safeParse(filename)
|
|
||||||
if (!validatedFilename.success) {
|
|
||||||
console.error(
|
|
||||||
'Invalid asset filename:',
|
|
||||||
validatedFilename.error.errors,
|
|
||||||
'for asset:',
|
|
||||||
assetId
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelect(validatedFilename.data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to fetch asset details for ${assetId}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFilters(newFilters: FilterState) {
|
function updateFilters(newFilters: FilterState) {
|
||||||
filters.value = { ...newFilters }
|
filters.value = { ...newFilters }
|
||||||
}
|
}
|
||||||
@@ -231,8 +200,8 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
|
|||||||
selectedCategory,
|
selectedCategory,
|
||||||
availableCategories,
|
availableCategories,
|
||||||
contentTitle,
|
contentTitle,
|
||||||
|
categoryFilteredAssets,
|
||||||
filteredAssets,
|
filteredAssets,
|
||||||
selectAssetWithCallback,
|
|
||||||
updateFilters
|
updateFilters
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,54 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { assetService } from '@/platform/assets/services/assetService'
|
import { assetService } from '@/platform/assets/services/assetService'
|
||||||
import { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore'
|
import { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
interface AssetBrowserDialogProps {
|
interface ShowOptions {
|
||||||
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
|
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
|
||||||
nodeType: string
|
nodeType: string
|
||||||
/** Widget input name (e.g., 'ckpt_name') */
|
/** Widget input name (e.g., 'ckpt_name') */
|
||||||
inputName: string
|
inputName: string
|
||||||
/** Current selected asset value */
|
/** Current selected asset value */
|
||||||
currentValue?: string
|
currentValue?: string
|
||||||
/**
|
onAssetSelected?: (asset: AssetItem) => void
|
||||||
* Callback for when an asset is selected
|
|
||||||
* @param {string} filename - The validated filename from user_metadata.filename
|
|
||||||
*/
|
|
||||||
onAssetSelected?: (filename: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BrowseOptions {
|
||||||
|
/** Asset type tag to filter by (e.g., 'models') */
|
||||||
|
assetType: string
|
||||||
|
/** Custom modal title (optional) */
|
||||||
|
title?: string
|
||||||
|
/** Called when asset selected */
|
||||||
|
onAssetSelected?: (asset: AssetItem) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogComponentProps: DialogComponentProps = {
|
||||||
|
headless: true,
|
||||||
|
modal: true,
|
||||||
|
closable: true,
|
||||||
|
pt: {
|
||||||
|
root: {
|
||||||
|
class: 'rounded-2xl overflow-hidden asset-browser-dialog'
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
class: '!p-0 hidden'
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
class: '!p-0 !m-0 h-full w-full'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
export const useAssetBrowserDialog = () => {
|
export const useAssetBrowserDialog = () => {
|
||||||
const dialogStore = useDialogStore()
|
const dialogStore = useDialogStore()
|
||||||
const dialogKey = 'global-asset-browser'
|
const dialogKey = 'global-asset-browser'
|
||||||
|
|
||||||
async function show(props: AssetBrowserDialogProps) {
|
async function show(props: ShowOptions) {
|
||||||
const handleAssetSelected = (filename: string) => {
|
const handleAssetSelected = (asset: AssetItem) => {
|
||||||
props.onAssetSelected?.(filename)
|
props.onAssetSelected?.(asset)
|
||||||
dialogStore.closeDialog({ key: dialogKey })
|
dialogStore.closeDialog({ key: dialogKey })
|
||||||
}
|
}
|
||||||
const dialogComponentProps: DialogComponentProps = {
|
|
||||||
headless: true,
|
|
||||||
modal: true,
|
|
||||||
closable: true,
|
|
||||||
pt: {
|
|
||||||
root: {
|
|
||||||
class: 'rounded-2xl overflow-hidden asset-browser-dialog'
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
class: '!p-0 hidden'
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
class: '!p-0 !m-0 h-full w-full'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const assets: AssetItem[] = await assetService
|
const assets: AssetItem[] = await assetService
|
||||||
.getAssetsForNodeType(props.nodeType)
|
.getAssetsForNodeType(props.nodeType)
|
||||||
@@ -54,6 +61,22 @@ export const useAssetBrowserDialog = () => {
|
|||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Extract node type category from first asset's tags (e.g., "loras", "checkpoints")
|
||||||
|
// Tags are ordered: ["models", "loras"] so take the second tag
|
||||||
|
const nodeTypeCategory =
|
||||||
|
assets[0]?.tags?.find((tag) => tag !== 'models') ?? 'models'
|
||||||
|
|
||||||
|
const acronyms = new Set(['VAE', 'CLIP', 'GLIGEN'])
|
||||||
|
const categoryLabel = nodeTypeCategory
|
||||||
|
.split('_')
|
||||||
|
.map((word) => {
|
||||||
|
const uc = word.toUpperCase()
|
||||||
|
return acronyms.has(uc) ? uc : word
|
||||||
|
})
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
const title = t('assetBrowser.allCategory', { category: categoryLabel })
|
||||||
|
|
||||||
dialogStore.showDialog({
|
dialogStore.showDialog({
|
||||||
key: dialogKey,
|
key: dialogKey,
|
||||||
component: AssetBrowserModal,
|
component: AssetBrowserModal,
|
||||||
@@ -62,6 +85,7 @@ export const useAssetBrowserDialog = () => {
|
|||||||
inputName: props.inputName,
|
inputName: props.inputName,
|
||||||
currentValue: props.currentValue,
|
currentValue: props.currentValue,
|
||||||
assets,
|
assets,
|
||||||
|
title,
|
||||||
onSelect: handleAssetSelected,
|
onSelect: handleAssetSelected,
|
||||||
onClose: () => dialogStore.closeDialog({ key: dialogKey })
|
onClose: () => dialogStore.closeDialog({ key: dialogKey })
|
||||||
},
|
},
|
||||||
@@ -69,5 +93,38 @@ export const useAssetBrowserDialog = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { show }
|
async function browse(options: BrowseOptions): Promise<void> {
|
||||||
|
const handleAssetSelected = (asset: AssetItem) => {
|
||||||
|
options.onAssetSelected?.(asset)
|
||||||
|
dialogStore.closeDialog({ key: dialogKey })
|
||||||
|
}
|
||||||
|
|
||||||
|
const assets = await assetService
|
||||||
|
.getAssetsByTag(options.assetType)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(
|
||||||
|
'Failed to fetch assets for tag:',
|
||||||
|
options.assetType,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
dialogStore.showDialog({
|
||||||
|
key: dialogKey,
|
||||||
|
component: AssetBrowserModal,
|
||||||
|
props: {
|
||||||
|
nodeType: undefined,
|
||||||
|
inputName: undefined,
|
||||||
|
assets,
|
||||||
|
showLeftPanel: true,
|
||||||
|
title: options.title,
|
||||||
|
onSelect: handleAssetSelected,
|
||||||
|
onClose: () => dialogStore.closeDialog({ key: dialogKey })
|
||||||
|
},
|
||||||
|
dialogComponentProps
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { show, browse }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { uniqWith } from 'es-toolkit'
|
import { uniqWith } from 'es-toolkit'
|
||||||
import { computed } from 'vue'
|
import { type MaybeRefOrGetter, computed, toValue } from 'vue'
|
||||||
|
|
||||||
import type { SelectOption } from '@/components/input/types'
|
import type { SelectOption } from '@/components/input/types'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
@@ -8,13 +8,14 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|||||||
* Composable that extracts available filter options from asset data
|
* Composable that extracts available filter options from asset data
|
||||||
* Provides reactive computed properties for file formats and base models
|
* Provides reactive computed properties for file formats and base models
|
||||||
*/
|
*/
|
||||||
export function useAssetFilterOptions(assets: AssetItem[] = []) {
|
export function useAssetFilterOptions(assets: MaybeRefOrGetter<AssetItem[]>) {
|
||||||
/**
|
/**
|
||||||
* Extract unique file formats from asset names
|
* Extract unique file formats from asset names
|
||||||
* Returns sorted SelectOption array with extensions
|
* Returns sorted SelectOption array with extensions
|
||||||
*/
|
*/
|
||||||
const availableFileFormats = computed<SelectOption[]>(() => {
|
const availableFileFormats = computed<SelectOption[]>(() => {
|
||||||
const extensions = assets
|
const assetList = toValue(assets)
|
||||||
|
const extensions = assetList
|
||||||
.map((asset) => {
|
.map((asset) => {
|
||||||
const extension = asset.name.split('.').pop()
|
const extension = asset.name.split('.').pop()
|
||||||
return extension && extension !== asset.name ? extension : null
|
return extension && extension !== asset.name ? extension : null
|
||||||
@@ -34,7 +35,8 @@ export function useAssetFilterOptions(assets: AssetItem[] = []) {
|
|||||||
* Returns sorted SelectOption array with base model names
|
* Returns sorted SelectOption array with base model names
|
||||||
*/
|
*/
|
||||||
const availableBaseModels = computed<SelectOption[]>(() => {
|
const availableBaseModels = computed<SelectOption[]>(() => {
|
||||||
const models = assets
|
const assetList = toValue(assets)
|
||||||
|
const models = assetList
|
||||||
.map((asset) => asset.user_metadata?.base_model)
|
.map((asset) => asset.user_metadata?.base_model)
|
||||||
.filter(
|
.filter(
|
||||||
(baseModel): baseModel is string =>
|
(baseModel): baseModel is string =>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const assetFilenameSchema = z
|
|||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
// Export schemas following repository patterns
|
// Export schemas following repository patterns
|
||||||
|
export const assetItemSchema = zAsset
|
||||||
export const assetResponseSchema = zAssetResponse
|
export const assetResponseSchema = zAssetResponse
|
||||||
|
|
||||||
// Export types derived from Zod schemas
|
// Export types derived from Zod schemas
|
||||||
|
|||||||
@@ -11,14 +11,11 @@ import { api } from '@/scripts/api'
|
|||||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||||
|
|
||||||
const ASSETS_ENDPOINT = '/assets'
|
const ASSETS_ENDPOINT = '/assets'
|
||||||
const MODELS_TAG = 'models'
|
|
||||||
const MISSING_TAG = 'missing'
|
|
||||||
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
|
||||||
|
const DEFAULT_LIMIT = 300
|
||||||
|
|
||||||
/**
|
export const MODELS_TAG = 'models'
|
||||||
* Input names that are eligible for asset browser
|
export const MISSING_TAG = 'missing'
|
||||||
*/
|
|
||||||
const WHITELISTED_INPUTS = new Set(['ckpt_name', 'lora_name', 'vae_name'])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates asset response data using Zod schema
|
* Validates asset response data using Zod schema
|
||||||
@@ -66,7 +63,7 @@ function createAssetService() {
|
|||||||
*/
|
*/
|
||||||
async function getAssetModelFolders(): Promise<ModelFolder[]> {
|
async function getAssetModelFolders(): Promise<ModelFolder[]> {
|
||||||
const data = await handleAssetRequest(
|
const data = await handleAssetRequest(
|
||||||
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}`,
|
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}&limit=${DEFAULT_LIMIT}`,
|
||||||
'model folders'
|
'model folders'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -95,7 +92,7 @@ function createAssetService() {
|
|||||||
*/
|
*/
|
||||||
async function getAssetModels(folder: string): Promise<ModelFile[]> {
|
async function getAssetModels(folder: string): Promise<ModelFile[]> {
|
||||||
const data = await handleAssetRequest(
|
const data = await handleAssetRequest(
|
||||||
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}`,
|
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}&limit=${DEFAULT_LIMIT}`,
|
||||||
`models for ${folder}`
|
`models for ${folder}`
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,19 +112,12 @@ function createAssetService() {
|
|||||||
/**
|
/**
|
||||||
* Checks if a widget input should use the asset browser based on both input name and node comfyClass
|
* Checks if a widget input should use the asset browser based on both input name and node comfyClass
|
||||||
*
|
*
|
||||||
* @param inputName - The input name (e.g., 'ckpt_name', 'lora_name')
|
|
||||||
* @param nodeType - The ComfyUI node comfyClass (e.g., 'CheckpointLoaderSimple', 'LoraLoader')
|
* @param nodeType - The ComfyUI node comfyClass (e.g., 'CheckpointLoaderSimple', 'LoraLoader')
|
||||||
* @returns true if this input should use asset browser
|
* @returns true if this input should use asset browser
|
||||||
*/
|
*/
|
||||||
function isAssetBrowserEligible(
|
function isAssetBrowserEligible(nodeType: string = ''): boolean {
|
||||||
inputName: string,
|
|
||||||
nodeType: string
|
|
||||||
): boolean {
|
|
||||||
return (
|
return (
|
||||||
// Must be an approved input name
|
!!nodeType && useModelToNodeStore().getRegisteredNodeTypes().has(nodeType)
|
||||||
WHITELISTED_INPUTS.has(inputName) &&
|
|
||||||
// Must be a registered node type
|
|
||||||
useModelToNodeStore().getRegisteredNodeTypes().has(nodeType)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +143,7 @@ function createAssetService() {
|
|||||||
|
|
||||||
// Fetch assets for this category using same API pattern as getAssetModels
|
// Fetch assets for this category using same API pattern as getAssetModels
|
||||||
const data = await handleAssetRequest(
|
const data = await handleAssetRequest(
|
||||||
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}`,
|
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}&limit=${DEFAULT_LIMIT}`,
|
||||||
`assets for ${nodeType}`
|
`assets for ${nodeType}`
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -196,12 +186,30 @@ function createAssetService() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets assets filtered by a specific tag
|
||||||
|
*
|
||||||
|
* @param tag - The tag to filter by (e.g., 'models')
|
||||||
|
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets
|
||||||
|
*/
|
||||||
|
async function getAssetsByTag(tag: string): Promise<AssetItem[]> {
|
||||||
|
const data = await handleAssetRequest(
|
||||||
|
`${ASSETS_ENDPOINT}?include_tags=${tag}&limit=${DEFAULT_LIMIT}`,
|
||||||
|
`assets for tag ${tag}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
data?.assets?.filter((asset) => !asset.tags.includes(MISSING_TAG)) ?? []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getAssetModelFolders,
|
getAssetModelFolders,
|
||||||
getAssetModels,
|
getAssetModels,
|
||||||
isAssetBrowserEligible,
|
isAssetBrowserEligible,
|
||||||
getAssetsForNodeType,
|
getAssetsForNodeType,
|
||||||
getAssetDetails
|
getAssetDetails,
|
||||||
|
getAssetsByTag
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
211
src/platform/assets/utils/createModelNodeFromAsset.ts
Normal file
211
src/platform/assets/utils/createModelNodeFromAsset.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import {
|
||||||
|
type LGraphNode,
|
||||||
|
LiteGraph,
|
||||||
|
type Point
|
||||||
|
} from '@/lib/litegraph/src/litegraph'
|
||||||
|
import {
|
||||||
|
type AssetItem,
|
||||||
|
assetItemSchema
|
||||||
|
} from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import {
|
||||||
|
MISSING_TAG,
|
||||||
|
MODELS_TAG
|
||||||
|
} from '@/platform/assets/services/assetService'
|
||||||
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
|
import { app } from '@/scripts/app'
|
||||||
|
import { useLitegraphService } from '@/services/litegraphService'
|
||||||
|
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||||
|
|
||||||
|
interface CreateNodeOptions {
|
||||||
|
position?: Point
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeCreationErrorCode =
|
||||||
|
| 'INVALID_ASSET'
|
||||||
|
| 'NO_PROVIDER'
|
||||||
|
| 'NODE_CREATION_FAILED'
|
||||||
|
| 'MISSING_WIDGET'
|
||||||
|
| 'NO_GRAPH'
|
||||||
|
|
||||||
|
interface NodeCreationError {
|
||||||
|
code: NodeCreationErrorCode
|
||||||
|
message: string
|
||||||
|
assetId: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result<T, E> = { success: true; value: T } | { success: false; error: E }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a LiteGraph node from an asset item.
|
||||||
|
*
|
||||||
|
* **Boundary Function**: Bridges Vue reactive domain with LiteGraph canvas domain.
|
||||||
|
*
|
||||||
|
* @param asset - Asset item to create node from (Vue domain)
|
||||||
|
* @param options - Optional position and configuration
|
||||||
|
* @returns Result with LiteGraph node (Canvas domain) or error details
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* This function performs side effects on the canvas graph. Validation failures
|
||||||
|
* return error results rather than throwing to allow graceful degradation in UI contexts.
|
||||||
|
* Widget validation occurs before graph mutation to prevent orphaned nodes.
|
||||||
|
*/
|
||||||
|
export function createModelNodeFromAsset(
|
||||||
|
asset: AssetItem,
|
||||||
|
options?: CreateNodeOptions
|
||||||
|
): Result<LGraphNode, NodeCreationError> {
|
||||||
|
const validatedAsset = assetItemSchema.safeParse(asset)
|
||||||
|
|
||||||
|
if (!validatedAsset.success) {
|
||||||
|
const errorMessage = validatedAsset.error.errors
|
||||||
|
.map((e) => `${e.path.join('.')}: ${e.message}`)
|
||||||
|
.join(', ')
|
||||||
|
console.error('Invalid asset item:', errorMessage)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INVALID_ASSET',
|
||||||
|
message: 'Asset schema validation failed',
|
||||||
|
assetId: asset.id,
|
||||||
|
details: { validationErrors: errorMessage }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validAsset = validatedAsset.data
|
||||||
|
|
||||||
|
const userMetadata = validAsset.user_metadata
|
||||||
|
if (!userMetadata) {
|
||||||
|
console.error(`Asset ${validAsset.id} missing required user_metadata`)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INVALID_ASSET',
|
||||||
|
message: 'Asset missing required user_metadata',
|
||||||
|
assetId: validAsset.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = userMetadata.filename
|
||||||
|
if (typeof filename !== 'string' || filename.length === 0) {
|
||||||
|
console.error(
|
||||||
|
`Asset ${validAsset.id} has invalid user_metadata.filename (expected non-empty string, got ${typeof filename})`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INVALID_ASSET',
|
||||||
|
message: `Invalid filename (expected non-empty string, got ${typeof filename})`,
|
||||||
|
assetId: validAsset.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validAsset.tags.length === 0) {
|
||||||
|
console.error(
|
||||||
|
`Asset ${validAsset.id} has no tags defined (expected at least one category tag)`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INVALID_ASSET',
|
||||||
|
message: 'Asset has no tags defined',
|
||||||
|
assetId: validAsset.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = validAsset.tags.find(
|
||||||
|
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
|
||||||
|
)
|
||||||
|
if (!category) {
|
||||||
|
console.error(
|
||||||
|
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INVALID_ASSET',
|
||||||
|
message: 'Asset has no valid category tag',
|
||||||
|
assetId: validAsset.id,
|
||||||
|
details: { availableTags: validAsset.tags }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelToNodeStore = useModelToNodeStore()
|
||||||
|
const provider = modelToNodeStore.getNodeProvider(category)
|
||||||
|
if (!provider) {
|
||||||
|
console.error(`No node provider registered for category: ${category}`)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'NO_PROVIDER',
|
||||||
|
message: `No node provider registered for category: ${category}`,
|
||||||
|
assetId: validAsset.id,
|
||||||
|
details: { category }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const litegraphService = useLitegraphService()
|
||||||
|
const pos = options?.position ?? litegraphService.getCanvasCenter()
|
||||||
|
|
||||||
|
const node = LiteGraph.createNode(
|
||||||
|
provider.nodeDef.name,
|
||||||
|
provider.nodeDef.display_name,
|
||||||
|
{ pos }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
console.error(`Failed to create node for type: ${provider.nodeDef.name}`)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'NODE_CREATION_FAILED',
|
||||||
|
message: `Failed to create node for type: ${provider.nodeDef.name}`,
|
||||||
|
assetId: validAsset.id,
|
||||||
|
details: { nodeType: provider.nodeDef.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowStore = useWorkflowStore()
|
||||||
|
const targetGraph = workflowStore.isSubgraphActive
|
||||||
|
? workflowStore.activeSubgraph
|
||||||
|
: app.canvas.graph
|
||||||
|
|
||||||
|
if (!targetGraph) {
|
||||||
|
console.error('No active graph available')
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'NO_GRAPH',
|
||||||
|
message: 'No active graph available',
|
||||||
|
assetId: validAsset.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const widget = node.widgets?.find((w) => w.name === provider.key)
|
||||||
|
if (!widget) {
|
||||||
|
console.error(
|
||||||
|
`Widget ${provider.key} not found on node ${provider.nodeDef.name}`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'MISSING_WIDGET',
|
||||||
|
message: `Widget ${provider.key} not found on node ${provider.nodeDef.name}`,
|
||||||
|
assetId: validAsset.id,
|
||||||
|
details: { widgetName: provider.key, nodeType: provider.nodeDef.name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetGraph.add(node)
|
||||||
|
widget.value = filename
|
||||||
|
|
||||||
|
return { success: true, value: node }
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|||||||
import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
|
import { isAssetWidget, isComboWidget } from '@/lib/litegraph/src/litegraph'
|
||||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||||
|
import {
|
||||||
|
assetFilenameSchema,
|
||||||
|
assetItemSchema
|
||||||
|
} from '@/platform/assets/schemas/assetSchema'
|
||||||
import { assetService } from '@/platform/assets/services/assetService'
|
import { assetService } from '@/platform/assets/services/assetService'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
|
||||||
@@ -62,13 +66,9 @@ const addComboWidget = (
|
|||||||
): IBaseWidget => {
|
): IBaseWidget => {
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
|
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||||
const isEligible = assetService.isAssetBrowserEligible(
|
const isEligible = assetService.isAssetBrowserEligible(node.comfyClass)
|
||||||
inputSpec.name,
|
|
||||||
node.comfyClass || ''
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isUsingAssetAPI && isEligible) {
|
if (isUsingAssetAPI && isEligible) {
|
||||||
// Get the default value for the button text (currently selected model)
|
|
||||||
const currentValue = getDefaultValue(inputSpec)
|
const currentValue = getDefaultValue(inputSpec)
|
||||||
const displayLabel = currentValue ?? t('widgets.selectModel')
|
const displayLabel = currentValue ?? t('widgets.selectModel')
|
||||||
|
|
||||||
@@ -86,11 +86,40 @@ const addComboWidget = (
|
|||||||
nodeType: node.comfyClass || '',
|
nodeType: node.comfyClass || '',
|
||||||
inputName: inputSpec.name,
|
inputName: inputSpec.name,
|
||||||
currentValue: widget.value,
|
currentValue: widget.value,
|
||||||
onAssetSelected: (filename: string) => {
|
onAssetSelected: (asset) => {
|
||||||
|
const validatedAsset = assetItemSchema.safeParse(asset)
|
||||||
|
|
||||||
|
if (!validatedAsset.success) {
|
||||||
|
console.error(
|
||||||
|
'Invalid asset item:',
|
||||||
|
validatedAsset.error.errors,
|
||||||
|
'Received:',
|
||||||
|
asset
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = validatedAsset.data.user_metadata?.filename
|
||||||
|
const validatedFilename = assetFilenameSchema.safeParse(filename)
|
||||||
|
|
||||||
|
if (!validatedFilename.success) {
|
||||||
|
console.error(
|
||||||
|
'Invalid asset filename:',
|
||||||
|
validatedFilename.error.errors,
|
||||||
|
'for asset:',
|
||||||
|
validatedAsset.data.id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const oldValue = widget.value
|
const oldValue = widget.value
|
||||||
widget.value = filename
|
widget.value = validatedFilename.data
|
||||||
// Using onWidgetChanged prevents a callback race where asset selection could reopen the dialog
|
node.onWidgetChanged?.(
|
||||||
node.onWidgetChanged?.(widget.name, filename, oldValue, widget)
|
widget.name,
|
||||||
|
validatedFilename.data,
|
||||||
|
oldValue,
|
||||||
|
widget
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLib
|
|||||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||||
import { useQueueSidebarTab } from '@/composables/sidebarTabs/useQueueSidebarTab'
|
import { useQueueSidebarTab } from '@/composables/sidebarTabs/useQueueSidebarTab'
|
||||||
import { t, te } from '@/i18n'
|
import { t, te } from '@/i18n'
|
||||||
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useWorkflowsSidebarTab } from '@/platform/workflow/management/composables/useWorkflowsSidebarTab'
|
import { useWorkflowsSidebarTab } from '@/platform/workflow/management/composables/useWorkflowsSidebarTab'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||||
@@ -63,7 +64,20 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
|||||||
tooltip: tooltipFunction,
|
tooltip: tooltipFunction,
|
||||||
versionAdded: '1.3.9',
|
versionAdded: '1.3.9',
|
||||||
category: 'view-controls' as const,
|
category: 'view-controls' as const,
|
||||||
function: () => {
|
function: async () => {
|
||||||
|
const settingStore = useSettingStore()
|
||||||
|
const commandStore = useCommandStore()
|
||||||
|
|
||||||
|
if (
|
||||||
|
tab.id === 'model-library' &&
|
||||||
|
settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||||
|
) {
|
||||||
|
await commandStore.commands
|
||||||
|
.find((cmd) => cmd.id === 'Comfy.BrowseModelAssets')
|
||||||
|
?.function?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
toggleSidebarTab(tab.id)
|
toggleSidebarTab(tab.id)
|
||||||
},
|
},
|
||||||
active: () => activeSidebarTab.value?.id === tab.id,
|
active: () => activeSidebarTab.value?.id === tab.id,
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { nextTick } from 'vue'
|
|
||||||
|
|
||||||
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
|
||||||
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
|
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
|
||||||
// Mock @/i18n for useAssetBrowser and AssetFilterBar
|
// Mock @/i18n for useAssetBrowser and AssetFilterBar
|
||||||
@@ -57,6 +55,9 @@ vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
|
|||||||
<div data-testid="header">
|
<div data-testid="header">
|
||||||
<slot name="header" />
|
<slot name="header" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="$slots.contentFilter" data-testid="content-filter">
|
||||||
|
<slot name="contentFilter" />
|
||||||
|
</div>
|
||||||
<div data-testid="content">
|
<div data-testid="content">
|
||||||
<slot name="content" />
|
<slot name="content" />
|
||||||
</div>
|
</div>
|
||||||
@@ -72,6 +73,9 @@ vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({
|
|||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue'],
|
||||||
template: `
|
template: `
|
||||||
<div data-testid="left-side-panel">
|
<div data-testid="left-side-panel">
|
||||||
|
<div v-if="$slots['header-title']" data-testid="header-title">
|
||||||
|
<slot name="header-title" />
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
v-for="item in navItems"
|
v-for="item in navItems"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@@ -86,6 +90,19 @@ vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/platform/assets/components/AssetFilterBar.vue', () => ({
|
||||||
|
default: {
|
||||||
|
name: 'AssetFilterBar',
|
||||||
|
props: ['assets'],
|
||||||
|
emits: ['filter-change'],
|
||||||
|
template: `
|
||||||
|
<div data-testid="asset-filter-bar">
|
||||||
|
Filter bar with {{ assets?.length ?? 0 }} assets
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({
|
vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({
|
||||||
default: {
|
default: {
|
||||||
name: 'AssetGrid',
|
name: 'AssetGrid',
|
||||||
@@ -169,99 +186,33 @@ describe('AssetBrowserModal', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('Search Functionality', () => {
|
describe('Integration with useAssetBrowser', () => {
|
||||||
it('filters assets when search query changes', async () => {
|
it('passes filteredAssets from composable to AssetGrid', () => {
|
||||||
const assets = [
|
const assets = [
|
||||||
createTestAsset('asset1', 'Checkpoint Model A', 'checkpoints'),
|
createTestAsset('asset1', 'Model A', 'checkpoints'),
|
||||||
createTestAsset('asset2', 'Checkpoint Model B', 'checkpoints'),
|
createTestAsset('asset2', 'Model B', 'loras')
|
||||||
createTestAsset('asset3', 'LoRA Model C', 'loras')
|
|
||||||
]
|
]
|
||||||
const wrapper = createWrapper(assets)
|
const wrapper = createWrapper(assets)
|
||||||
|
|
||||||
const searchBox = wrapper.find('[data-testid="search-box"]')
|
|
||||||
|
|
||||||
// Search for "Checkpoint"
|
|
||||||
await searchBox.setValue('Checkpoint')
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
// Should filter to only checkpoint assets
|
|
||||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
|
||||||
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
|
|
||||||
|
|
||||||
expect(filteredAssets.length).toBe(2)
|
|
||||||
expect(
|
|
||||||
filteredAssets.every((asset: AssetDisplayItem) =>
|
|
||||||
asset.name.includes('Checkpoint')
|
|
||||||
)
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('search is case insensitive', async () => {
|
|
||||||
const assets = [
|
|
||||||
createTestAsset('asset1', 'LoRA Model C', 'loras'),
|
|
||||||
createTestAsset('asset2', 'Checkpoint Model', 'checkpoints')
|
|
||||||
]
|
|
||||||
const wrapper = createWrapper(assets)
|
|
||||||
|
|
||||||
const searchBox = wrapper.find('[data-testid="search-box"]')
|
|
||||||
|
|
||||||
// Search with different case
|
|
||||||
await searchBox.setValue('lora')
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
||||||
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
|
const gridAssets = assetGrid.props('assets')
|
||||||
|
|
||||||
expect(filteredAssets.length).toBe(1)
|
expect(gridAssets).toHaveLength(2)
|
||||||
expect(filteredAssets[0].name).toContain('LoRA')
|
expect(gridAssets[0].id).toBe('asset1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows empty state when search has no results', async () => {
|
it('passes categoryFilteredAssets to AssetFilterBar', () => {
|
||||||
const assets = [
|
const assets = [
|
||||||
createTestAsset('asset1', 'Checkpoint Model', 'checkpoints')
|
createTestAsset('c1', 'model.safetensors', 'checkpoints'),
|
||||||
]
|
createTestAsset('l1', 'lora.pt', 'loras')
|
||||||
const wrapper = createWrapper(assets)
|
|
||||||
|
|
||||||
const searchBox = wrapper.find('[data-testid="search-box"]')
|
|
||||||
|
|
||||||
// Search for something that doesn't exist
|
|
||||||
await searchBox.setValue('nonexistent')
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
expect(wrapper.find('[data-testid="empty-state"]').exists()).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Category Navigation', () => {
|
|
||||||
it('filters assets by selected category', async () => {
|
|
||||||
const assets = [
|
|
||||||
createTestAsset('asset1', 'Checkpoint Model A', 'checkpoints'),
|
|
||||||
createTestAsset('asset2', 'LoRA Model C', 'loras'),
|
|
||||||
createTestAsset('asset3', 'VAE Model D', 'vae')
|
|
||||||
]
|
]
|
||||||
const wrapper = createWrapper(assets, { showLeftPanel: true })
|
const wrapper = createWrapper(assets, { showLeftPanel: true })
|
||||||
|
|
||||||
// Wait for Vue reactivity and component mounting
|
const filterBar = wrapper.findComponent({ name: 'AssetFilterBar' })
|
||||||
await nextTick()
|
const filterBarAssets = filterBar.props('assets')
|
||||||
|
|
||||||
// Check if left panel exists first (since we have multiple categories)
|
// Should initially show all assets
|
||||||
const leftPanel = wrapper.find('[data-testid="left-panel"]')
|
expect(filterBarAssets).toHaveLength(2)
|
||||||
expect(leftPanel.exists()).toBe(true)
|
|
||||||
|
|
||||||
// Check if the nav item exists before clicking
|
|
||||||
const lorasNavItem = wrapper.find('[data-testid="nav-item-loras"]')
|
|
||||||
expect(lorasNavItem.exists()).toBe(true)
|
|
||||||
|
|
||||||
// Click the loras category
|
|
||||||
await lorasNavItem.trigger('click')
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
// Should filter to only LoRA assets
|
|
||||||
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
|
|
||||||
const filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
|
|
||||||
|
|
||||||
expect(filteredAssets.length).toBe(1)
|
|
||||||
expect(filteredAssets[0].name).toContain('LoRA')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -277,7 +228,7 @@ describe('AssetBrowserModal', () => {
|
|||||||
expect(emitted).toBeDefined()
|
expect(emitted).toBeDefined()
|
||||||
expect(emitted).toHaveLength(1)
|
expect(emitted).toHaveLength(1)
|
||||||
|
|
||||||
const emittedAsset = emitted![0][0] as AssetDisplayItem
|
const emittedAsset = emitted![0][0] as AssetItem
|
||||||
expect(emittedAsset.id).toBe('asset1')
|
expect(emittedAsset.id).toBe('asset1')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -289,7 +240,12 @@ describe('AssetBrowserModal', () => {
|
|||||||
// Click on first asset
|
// Click on first asset
|
||||||
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
|
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
|
||||||
|
|
||||||
expect(onSelectSpy).toHaveBeenCalledWith('Test Model')
|
expect(onSelectSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'asset1',
|
||||||
|
name: 'Test Model'
|
||||||
|
})
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -327,4 +283,56 @@ describe('AssetBrowserModal', () => {
|
|||||||
expect(wrapper2.find('[data-testid="left-panel"]').exists()).toBe(false)
|
expect(wrapper2.find('[data-testid="left-panel"]').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Filter Options Reactivity', () => {
|
||||||
|
it('updates filter options when category changes', async () => {
|
||||||
|
const assets = [
|
||||||
|
createTestAsset('c1', 'model.safetensors', 'checkpoints'),
|
||||||
|
createTestAsset('c2', 'another.safetensors', 'checkpoints'),
|
||||||
|
createTestAsset('l1', 'lora.pt', 'loras')
|
||||||
|
]
|
||||||
|
const wrapper = createWrapper(assets, { showLeftPanel: true })
|
||||||
|
|
||||||
|
// Initially on "all" category - should have both .safetensors and .pt
|
||||||
|
const filterBar = wrapper.findComponent({ name: 'AssetFilterBar' })
|
||||||
|
expect(filterBar.exists()).toBe(true)
|
||||||
|
|
||||||
|
// Switch to checkpoints category
|
||||||
|
const checkpointsNav = wrapper.find(
|
||||||
|
'[data-testid="nav-item-checkpoints"]'
|
||||||
|
)
|
||||||
|
expect(checkpointsNav.exists()).toBe(true)
|
||||||
|
await checkpointsNav.trigger('click')
|
||||||
|
|
||||||
|
// Filter bar should receive only checkpoint assets now
|
||||||
|
const updatedFilterBar = wrapper.findComponent({ name: 'AssetFilterBar' })
|
||||||
|
const filterBarAssets = updatedFilterBar.props('assets')
|
||||||
|
|
||||||
|
expect(filterBarAssets).toHaveLength(2)
|
||||||
|
expect(
|
||||||
|
filterBarAssets.every((a: AssetItem) => a.tags.includes('checkpoints'))
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Title Management', () => {
|
||||||
|
it('passes custom title to BaseModalLayout when title prop provided', () => {
|
||||||
|
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
|
||||||
|
const customTitle = 'Model Library'
|
||||||
|
const wrapper = createWrapper(assets, { title: customTitle })
|
||||||
|
|
||||||
|
const baseModal = wrapper.findComponent({ name: 'BaseModalLayout' })
|
||||||
|
expect(baseModal.props('contentTitle')).toBe(customTitle)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes computed contentTitle to BaseModalLayout when no title prop', () => {
|
||||||
|
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
|
||||||
|
const wrapper = createWrapper(assets)
|
||||||
|
|
||||||
|
const baseModal = wrapper.findComponent({ name: 'BaseModalLayout' })
|
||||||
|
// Should use contentTitle from useAssetBrowser (e.g., "All Models")
|
||||||
|
expect(baseModal.props('contentTitle')).toBeTruthy()
|
||||||
|
expect(baseModal.props('contentTitle')).not.toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,13 +3,6 @@ import { nextTick } from 'vue'
|
|||||||
|
|
||||||
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { assetService } from '@/platform/assets/services/assetService'
|
|
||||||
|
|
||||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
|
||||||
assetService: {
|
|
||||||
getAssetDetails: vi.fn()
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
vi.mock('@/i18n', () => ({
|
vi.mock('@/i18n', () => ({
|
||||||
t: (key: string) => {
|
t: (key: string) => {
|
||||||
@@ -42,6 +35,38 @@ describe('useAssetBrowser', () => {
|
|||||||
...overrides
|
...overrides
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Category Filtering', () => {
|
||||||
|
it('exposes category-filtered assets for filter options', () => {
|
||||||
|
const checkpointAsset = createApiAsset({
|
||||||
|
id: 'checkpoint-1',
|
||||||
|
name: 'model.safetensors',
|
||||||
|
tags: ['models', 'checkpoints']
|
||||||
|
})
|
||||||
|
const loraAsset = createApiAsset({
|
||||||
|
id: 'lora-1',
|
||||||
|
name: 'lora.pt',
|
||||||
|
tags: ['models', 'loras']
|
||||||
|
})
|
||||||
|
|
||||||
|
const { selectedCategory, categoryFilteredAssets } = useAssetBrowser([
|
||||||
|
checkpointAsset,
|
||||||
|
loraAsset
|
||||||
|
])
|
||||||
|
|
||||||
|
// Initially should show all assets
|
||||||
|
expect(categoryFilteredAssets.value).toHaveLength(2)
|
||||||
|
|
||||||
|
// When category selected, should only show that category
|
||||||
|
selectedCategory.value = 'checkpoints'
|
||||||
|
expect(categoryFilteredAssets.value).toHaveLength(1)
|
||||||
|
expect(categoryFilteredAssets.value[0].id).toBe('checkpoint-1')
|
||||||
|
|
||||||
|
selectedCategory.value = 'loras'
|
||||||
|
expect(categoryFilteredAssets.value).toHaveLength(1)
|
||||||
|
expect(categoryFilteredAssets.value[0].id).toBe('lora-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Asset Transformation', () => {
|
describe('Asset Transformation', () => {
|
||||||
it('transforms API asset to include display properties', () => {
|
it('transforms API asset to include display properties', () => {
|
||||||
const apiAsset = createApiAsset({
|
const apiAsset = createApiAsset({
|
||||||
@@ -258,185 +283,6 @@ describe('useAssetBrowser', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Async Asset Selection with Detail Fetching', () => {
|
|
||||||
it('should fetch asset details and call onSelect with filename when provided', async () => {
|
|
||||||
const onSelectSpy = vi.fn()
|
|
||||||
const asset = createApiAsset({
|
|
||||||
id: 'asset-123',
|
|
||||||
name: 'test-model.safetensors'
|
|
||||||
})
|
|
||||||
|
|
||||||
const detailAsset = createApiAsset({
|
|
||||||
id: 'asset-123',
|
|
||||||
name: 'test-model.safetensors',
|
|
||||||
user_metadata: { filename: 'checkpoints/test-model.safetensors' }
|
|
||||||
})
|
|
||||||
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
|
|
||||||
|
|
||||||
const { selectAssetWithCallback } = useAssetBrowser([asset])
|
|
||||||
|
|
||||||
await selectAssetWithCallback(asset.id, onSelectSpy)
|
|
||||||
|
|
||||||
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-123')
|
|
||||||
expect(onSelectSpy).toHaveBeenCalledWith(
|
|
||||||
'checkpoints/test-model.safetensors'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle missing user_metadata.filename as error', async () => {
|
|
||||||
const consoleErrorSpy = vi
|
|
||||||
.spyOn(console, 'error')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
const onSelectSpy = vi.fn()
|
|
||||||
const asset = createApiAsset({ id: 'asset-456' })
|
|
||||||
|
|
||||||
const detailAsset = createApiAsset({
|
|
||||||
id: 'asset-456',
|
|
||||||
user_metadata: { filename: '' } // Invalid empty filename
|
|
||||||
})
|
|
||||||
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
|
|
||||||
|
|
||||||
const { selectAssetWithCallback } = useAssetBrowser([asset])
|
|
||||||
|
|
||||||
await selectAssetWithCallback(asset.id, onSelectSpy)
|
|
||||||
|
|
||||||
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-456')
|
|
||||||
expect(onSelectSpy).not.toHaveBeenCalled()
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
'Invalid asset filename:',
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
message: 'Filename cannot be empty'
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
'for asset:',
|
|
||||||
'asset-456'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle API errors gracefully', async () => {
|
|
||||||
const consoleErrorSpy = vi
|
|
||||||
.spyOn(console, 'error')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
const onSelectSpy = vi.fn()
|
|
||||||
const asset = createApiAsset({ id: 'asset-789' })
|
|
||||||
|
|
||||||
const apiError = new Error('API Error')
|
|
||||||
vi.mocked(assetService.getAssetDetails).mockRejectedValue(apiError)
|
|
||||||
|
|
||||||
const { selectAssetWithCallback } = useAssetBrowser([asset])
|
|
||||||
|
|
||||||
await selectAssetWithCallback(asset.id, onSelectSpy)
|
|
||||||
|
|
||||||
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-789')
|
|
||||||
expect(onSelectSpy).not.toHaveBeenCalled()
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('Failed to fetch asset details for asset-789'),
|
|
||||||
apiError
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not fetch details when no callback provided', async () => {
|
|
||||||
const asset = createApiAsset({ id: 'asset-no-callback' })
|
|
||||||
|
|
||||||
const { selectAssetWithCallback } = useAssetBrowser([asset])
|
|
||||||
|
|
||||||
await selectAssetWithCallback(asset.id)
|
|
||||||
|
|
||||||
expect(assetService.getAssetDetails).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Filename Validation Security', () => {
|
|
||||||
const createValidationTest = (filename: string) => {
|
|
||||||
const testAsset = createApiAsset({ id: 'validation-test' })
|
|
||||||
const detailAsset = createApiAsset({
|
|
||||||
id: 'validation-test',
|
|
||||||
user_metadata: { filename }
|
|
||||||
})
|
|
||||||
return { testAsset, detailAsset }
|
|
||||||
}
|
|
||||||
|
|
||||||
it('accepts valid file paths with forward slashes', async () => {
|
|
||||||
const onSelectSpy = vi.fn()
|
|
||||||
const { testAsset, detailAsset } = createValidationTest(
|
|
||||||
'models/checkpoints/v1/test-model.safetensors'
|
|
||||||
)
|
|
||||||
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
|
|
||||||
|
|
||||||
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
|
|
||||||
await selectAssetWithCallback(testAsset.id, onSelectSpy)
|
|
||||||
|
|
||||||
expect(onSelectSpy).toHaveBeenCalledWith(
|
|
||||||
'models/checkpoints/v1/test-model.safetensors'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects directory traversal attacks', async () => {
|
|
||||||
const consoleErrorSpy = vi
|
|
||||||
.spyOn(console, 'error')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
const onSelectSpy = vi.fn()
|
|
||||||
|
|
||||||
const maliciousPaths = [
|
|
||||||
'../malicious-model.safetensors',
|
|
||||||
'models/../../../etc/passwd',
|
|
||||||
'/etc/passwd'
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const path of maliciousPaths) {
|
|
||||||
const { testAsset, detailAsset } = createValidationTest(path)
|
|
||||||
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
|
|
||||||
|
|
||||||
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
|
|
||||||
await selectAssetWithCallback(testAsset.id, onSelectSpy)
|
|
||||||
|
|
||||||
expect(onSelectSpy).not.toHaveBeenCalled()
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
'Invalid asset filename:',
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
message: 'Path must not start with / or contain ..'
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
'for asset:',
|
|
||||||
'validation-test'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('rejects invalid filename characters', async () => {
|
|
||||||
const consoleErrorSpy = vi
|
|
||||||
.spyOn(console, 'error')
|
|
||||||
.mockImplementation(() => {})
|
|
||||||
const onSelectSpy = vi.fn()
|
|
||||||
|
|
||||||
const invalidChars = ['\\', ':', '*', '?', '"', '<', '>', '|']
|
|
||||||
|
|
||||||
for (const char of invalidChars) {
|
|
||||||
const { testAsset, detailAsset } = createValidationTest(
|
|
||||||
`bad${char}filename.safetensors`
|
|
||||||
)
|
|
||||||
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
|
|
||||||
|
|
||||||
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
|
|
||||||
await selectAssetWithCallback(testAsset.id, onSelectSpy)
|
|
||||||
|
|
||||||
expect(onSelectSpy).not.toHaveBeenCalled()
|
|
||||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
||||||
'Invalid asset filename:',
|
|
||||||
expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
message: 'Invalid filename characters'
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
'for asset:',
|
|
||||||
'validation-test'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Dynamic Category Extraction', () => {
|
describe('Dynamic Category Extraction', () => {
|
||||||
it('extracts categories from asset tags', () => {
|
it('extracts categories from asset tags', () => {
|
||||||
const assets = [
|
const assets = [
|
||||||
|
|||||||
@@ -1,91 +1,100 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
import { useDialogStore } from '@/stores/dialogStore'
|
import { useDialogStore } from '@/stores/dialogStore'
|
||||||
|
|
||||||
// Mock the dialog store
|
|
||||||
vi.mock('@/stores/dialogStore')
|
vi.mock('@/stores/dialogStore')
|
||||||
|
|
||||||
// Mock the asset service
|
vi.mock('@/i18n', () => ({
|
||||||
vi.mock('@/platform/assets/services/assetService', () => ({
|
t: (key: string, params?: Record<string, string>) => {
|
||||||
assetService: {
|
if (params) {
|
||||||
getAssetsForNodeType: vi.fn().mockResolvedValue([])
|
return `${key}:${JSON.stringify(params)}`
|
||||||
|
}
|
||||||
|
return key
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Test factory functions
|
vi.mock('@/platform/assets/services/assetService', () => ({
|
||||||
interface AssetBrowserProps {
|
assetService: {
|
||||||
nodeType: string
|
getAssetsForNodeType: vi.fn().mockResolvedValue([]),
|
||||||
inputName: string
|
getAssetsByTag: vi.fn().mockResolvedValue([])
|
||||||
onAssetSelected?: (filename: string) => void
|
}
|
||||||
}
|
}))
|
||||||
|
|
||||||
function createAssetBrowserProps(
|
const { assetService } = await import('@/platform/assets/services/assetService')
|
||||||
overrides: Partial<AssetBrowserProps> = {}
|
const mockGetAssetsByTag = vi.mocked(assetService.getAssetsByTag)
|
||||||
): AssetBrowserProps {
|
const mockGetAssetsForNodeType = vi.mocked(assetService.getAssetsForNodeType)
|
||||||
|
|
||||||
|
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||||
return {
|
return {
|
||||||
nodeType: 'CheckpointLoaderSimple',
|
id: 'asset-123',
|
||||||
inputName: 'ckpt_name',
|
name: 'test-model.safetensors',
|
||||||
|
size: 1024,
|
||||||
|
created_at: '2025-10-01T00:00:00Z',
|
||||||
|
tags: ['models', 'checkpoints'],
|
||||||
|
user_metadata: {
|
||||||
|
filename: 'models/checkpoints/test-model.safetensors'
|
||||||
|
},
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupDialogMocks() {
|
||||||
|
const mockShowDialog = vi.fn()
|
||||||
|
const mockCloseDialog = vi.fn()
|
||||||
|
vi.mocked(useDialogStore, { partial: true }).mockReturnValue({
|
||||||
|
showDialog: mockShowDialog,
|
||||||
|
closeDialog: mockCloseDialog
|
||||||
|
})
|
||||||
|
|
||||||
|
return { mockShowDialog, mockCloseDialog }
|
||||||
|
}
|
||||||
|
|
||||||
describe('useAssetBrowserDialog', () => {
|
describe('useAssetBrowserDialog', () => {
|
||||||
describe('Asset Selection Flow', () => {
|
describe('Asset Selection Flow', () => {
|
||||||
it('auto-closes dialog when asset is selected', async () => {
|
it('auto-closes dialog when asset is selected', async () => {
|
||||||
// Create fresh mocks for this test
|
const { mockShowDialog, mockCloseDialog } = setupDialogMocks()
|
||||||
const mockShowDialog = vi.fn()
|
|
||||||
const mockCloseDialog = vi.fn()
|
|
||||||
|
|
||||||
vi.mocked(useDialogStore).mockReturnValue({
|
|
||||||
showDialog: mockShowDialog,
|
|
||||||
closeDialog: mockCloseDialog
|
|
||||||
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
|
||||||
typeof useDialogStore
|
|
||||||
>)
|
|
||||||
|
|
||||||
const assetBrowserDialog = useAssetBrowserDialog()
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
const onAssetSelected = vi.fn()
|
const onAssetSelected = vi.fn()
|
||||||
const props = createAssetBrowserProps({ onAssetSelected })
|
|
||||||
|
|
||||||
await assetBrowserDialog.show(props)
|
await assetBrowserDialog.show({
|
||||||
|
nodeType: 'CheckpointLoaderSimple',
|
||||||
|
inputName: 'ckpt_name',
|
||||||
|
onAssetSelected
|
||||||
|
})
|
||||||
|
|
||||||
// Get the onSelect handler that was passed to the dialog
|
|
||||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||||
const onSelectHandler = dialogCall.props.onSelect
|
const onSelectHandler = dialogCall.props.onSelect
|
||||||
|
|
||||||
// Simulate asset selection
|
const mockAsset = {
|
||||||
onSelectHandler('selected-asset-path')
|
id: 'test-asset-id',
|
||||||
|
name: 'test.safetensors',
|
||||||
|
size: 1024,
|
||||||
|
created_at: '2025-10-01T00:00:00Z',
|
||||||
|
tags: ['models', 'checkpoints'],
|
||||||
|
user_metadata: { filename: 'selected-asset-path' }
|
||||||
|
}
|
||||||
|
onSelectHandler(mockAsset)
|
||||||
|
|
||||||
// Should call the original callback and trigger hide animation
|
expect(onAssetSelected).toHaveBeenCalledWith(mockAsset)
|
||||||
expect(onAssetSelected).toHaveBeenCalledWith('selected-asset-path')
|
|
||||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||||
key: 'global-asset-browser'
|
key: 'global-asset-browser'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('closes dialog when close handler is called', async () => {
|
it('closes dialog when close handler is called', async () => {
|
||||||
// Create fresh mocks for this test
|
const { mockShowDialog, mockCloseDialog } = setupDialogMocks()
|
||||||
const mockShowDialog = vi.fn()
|
|
||||||
const mockCloseDialog = vi.fn()
|
|
||||||
|
|
||||||
vi.mocked(useDialogStore).mockReturnValue({
|
|
||||||
showDialog: mockShowDialog,
|
|
||||||
closeDialog: mockCloseDialog
|
|
||||||
} as Partial<ReturnType<typeof useDialogStore>> as ReturnType<
|
|
||||||
typeof useDialogStore
|
|
||||||
>)
|
|
||||||
|
|
||||||
const assetBrowserDialog = useAssetBrowserDialog()
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
const props = createAssetBrowserProps()
|
|
||||||
|
|
||||||
await assetBrowserDialog.show(props)
|
await assetBrowserDialog.show({
|
||||||
|
nodeType: 'CheckpointLoaderSimple',
|
||||||
|
inputName: 'ckpt_name'
|
||||||
|
})
|
||||||
|
|
||||||
// Get the onClose handler that was passed to the dialog
|
|
||||||
const dialogCall = mockShowDialog.mock.calls[0][0]
|
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||||
const onCloseHandler = dialogCall.props.onClose
|
const onCloseHandler = dialogCall.props.onClose
|
||||||
|
|
||||||
// Simulate dialog close
|
|
||||||
onCloseHandler()
|
onCloseHandler()
|
||||||
|
|
||||||
expect(mockCloseDialog).toHaveBeenCalledWith({
|
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||||
@@ -93,4 +102,158 @@ describe('useAssetBrowserDialog', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('.browse() method', () => {
|
||||||
|
it('opens asset browser dialog with tag-based filtering', async () => {
|
||||||
|
const { mockShowDialog } = setupDialogMocks()
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
await assetBrowserDialog.browse({
|
||||||
|
assetType: 'models',
|
||||||
|
title: 'Model Library'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockShowDialog).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
key: 'global-asset-browser',
|
||||||
|
props: expect.objectContaining({
|
||||||
|
showLeftPanel: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onAssetSelected callback when asset is selected', async () => {
|
||||||
|
const { mockShowDialog } = setupDialogMocks()
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
const mockAsset = createMockAsset()
|
||||||
|
const onAssetSelected = vi.fn()
|
||||||
|
await assetBrowserDialog.browse({
|
||||||
|
assetType: 'models',
|
||||||
|
onAssetSelected
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||||
|
const onSelectHandler = dialogCall.props.onSelect
|
||||||
|
|
||||||
|
onSelectHandler(mockAsset)
|
||||||
|
|
||||||
|
expect(onAssetSelected).toHaveBeenCalledWith(mockAsset)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes dialog after asset selection', async () => {
|
||||||
|
const { mockShowDialog, mockCloseDialog } = setupDialogMocks()
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
const mockAsset = createMockAsset()
|
||||||
|
await assetBrowserDialog.browse({
|
||||||
|
assetType: 'models'
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||||
|
const onSelectHandler = dialogCall.props.onSelect
|
||||||
|
|
||||||
|
onSelectHandler(mockAsset)
|
||||||
|
|
||||||
|
expect(mockCloseDialog).toHaveBeenCalledWith({
|
||||||
|
key: 'global-asset-browser'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses custom title when provided', async () => {
|
||||||
|
const { mockShowDialog } = setupDialogMocks()
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
await assetBrowserDialog.browse({
|
||||||
|
assetType: 'models',
|
||||||
|
title: 'Custom Model Browser'
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||||
|
expect(dialogCall.props.title).toBe('Custom Model Browser')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls getAssetsByTag with correct assetType parameter', async () => {
|
||||||
|
setupDialogMocks()
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
await assetBrowserDialog.browse({
|
||||||
|
assetType: 'models'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockGetAssetsByTag).toHaveBeenCalledWith('models')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes fetched assets to dialog props', async () => {
|
||||||
|
const { mockShowDialog } = setupDialogMocks()
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
const mockAssets = [
|
||||||
|
createMockAsset({ id: 'asset-1', name: 'model1.safetensors' }),
|
||||||
|
createMockAsset({ id: 'asset-2', name: 'model2.safetensors' })
|
||||||
|
]
|
||||||
|
|
||||||
|
mockGetAssetsByTag.mockResolvedValueOnce(mockAssets)
|
||||||
|
await assetBrowserDialog.browse({
|
||||||
|
assetType: 'models'
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||||
|
expect(dialogCall.props.assets).toEqual(mockAssets)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles asset fetch errors gracefully', async () => {
|
||||||
|
const { mockShowDialog } = setupDialogMocks()
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
const consoleErrorSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
|
||||||
|
mockGetAssetsByTag.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
await assetBrowserDialog.browse({
|
||||||
|
assetType: 'models'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockShowDialog).toHaveBeenCalled()
|
||||||
|
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||||
|
expect(dialogCall.props.assets).toEqual([])
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Failed to fetch assets for tag:',
|
||||||
|
'models',
|
||||||
|
expect.any(Error)
|
||||||
|
)
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('.show() title formatting', () => {
|
||||||
|
it('formats title with VAE acronym uppercase', async () => {
|
||||||
|
const { mockShowDialog } = setupDialogMocks()
|
||||||
|
mockGetAssetsForNodeType.mockResolvedValueOnce([
|
||||||
|
createMockAsset({ tags: ['models', 'vae'] })
|
||||||
|
])
|
||||||
|
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
await assetBrowserDialog.show({
|
||||||
|
nodeType: 'VAELoader',
|
||||||
|
inputName: 'vae_name'
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||||
|
expect(dialogCall.props.title).toContain('VAE')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces underscores with spaces in tag names', async () => {
|
||||||
|
const { mockShowDialog } = setupDialogMocks()
|
||||||
|
mockGetAssetsForNodeType.mockResolvedValueOnce([
|
||||||
|
createMockAsset({ tags: ['models', 'style_models'] })
|
||||||
|
])
|
||||||
|
|
||||||
|
const assetBrowserDialog = useAssetBrowserDialog()
|
||||||
|
await assetBrowserDialog.show({
|
||||||
|
nodeType: 'StyleModelLoader',
|
||||||
|
inputName: 'style_model_name'
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogCall = mockShowDialog.mock.calls[0][0]
|
||||||
|
expect(dialogCall.props.title).toContain('style models')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe('useAssetFilterOptions', () => {
|
|||||||
createAssetWithSpecificExtension('pt')
|
createAssetWithSpecificExtension('pt')
|
||||||
]
|
]
|
||||||
|
|
||||||
const { availableFileFormats } = useAssetFilterOptions(assets)
|
const { availableFileFormats } = useAssetFilterOptions(() => assets)
|
||||||
|
|
||||||
expect(availableFileFormats.value).toEqual([
|
expect(availableFileFormats.value).toEqual([
|
||||||
{ name: '.ckpt', value: 'ckpt' },
|
{ name: '.ckpt', value: 'ckpt' },
|
||||||
@@ -34,7 +34,7 @@ describe('useAssetFilterOptions', () => {
|
|||||||
createAssetWithSpecificExtension('ckpt')
|
createAssetWithSpecificExtension('ckpt')
|
||||||
]
|
]
|
||||||
|
|
||||||
const { availableFileFormats } = useAssetFilterOptions(assets)
|
const { availableFileFormats } = useAssetFilterOptions(() => assets)
|
||||||
|
|
||||||
expect(availableFileFormats.value).toEqual([
|
expect(availableFileFormats.value).toEqual([
|
||||||
{ name: '.ckpt', value: 'ckpt' },
|
{ name: '.ckpt', value: 'ckpt' },
|
||||||
@@ -48,7 +48,7 @@ describe('useAssetFilterOptions', () => {
|
|||||||
createAssetWithSpecificExtension('safetensors')
|
createAssetWithSpecificExtension('safetensors')
|
||||||
]
|
]
|
||||||
|
|
||||||
const { availableFileFormats } = useAssetFilterOptions(assets)
|
const { availableFileFormats } = useAssetFilterOptions(() => assets)
|
||||||
|
|
||||||
expect(availableFileFormats.value).toEqual([
|
expect(availableFileFormats.value).toEqual([
|
||||||
{ name: '.safetensors', value: 'safetensors' }
|
{ name: '.safetensors', value: 'safetensors' }
|
||||||
@@ -56,7 +56,7 @@ describe('useAssetFilterOptions', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('handles empty asset list', () => {
|
it('handles empty asset list', () => {
|
||||||
const { availableFileFormats } = useAssetFilterOptions([])
|
const { availableFileFormats } = useAssetFilterOptions(() => [])
|
||||||
|
|
||||||
expect(availableFileFormats.value).toEqual([])
|
expect(availableFileFormats.value).toEqual([])
|
||||||
})
|
})
|
||||||
@@ -70,7 +70,7 @@ describe('useAssetFilterOptions', () => {
|
|||||||
createAssetWithSpecificBaseModel('sd35')
|
createAssetWithSpecificBaseModel('sd35')
|
||||||
]
|
]
|
||||||
|
|
||||||
const { availableBaseModels } = useAssetFilterOptions(assets)
|
const { availableBaseModels } = useAssetFilterOptions(() => assets)
|
||||||
|
|
||||||
expect(availableBaseModels.value).toEqual([
|
expect(availableBaseModels.value).toEqual([
|
||||||
{ name: 'sd15', value: 'sd15' },
|
{ name: 'sd15', value: 'sd15' },
|
||||||
@@ -86,7 +86,7 @@ describe('useAssetFilterOptions', () => {
|
|||||||
createAssetWithSpecificBaseModel('sdxl')
|
createAssetWithSpecificBaseModel('sdxl')
|
||||||
]
|
]
|
||||||
|
|
||||||
const { availableBaseModels } = useAssetFilterOptions(assets)
|
const { availableBaseModels } = useAssetFilterOptions(() => assets)
|
||||||
|
|
||||||
expect(availableBaseModels.value).toEqual([
|
expect(availableBaseModels.value).toEqual([
|
||||||
{ name: 'sd15', value: 'sd15' },
|
{ name: 'sd15', value: 'sd15' },
|
||||||
@@ -100,7 +100,7 @@ describe('useAssetFilterOptions', () => {
|
|||||||
createAssetWithSpecificBaseModel('sd15')
|
createAssetWithSpecificBaseModel('sd15')
|
||||||
]
|
]
|
||||||
|
|
||||||
const { availableBaseModels } = useAssetFilterOptions(assets)
|
const { availableBaseModels } = useAssetFilterOptions(() => assets)
|
||||||
|
|
||||||
expect(availableBaseModels.value).toEqual([
|
expect(availableBaseModels.value).toEqual([
|
||||||
{ name: 'sd15', value: 'sd15' }
|
{ name: 'sd15', value: 'sd15' }
|
||||||
@@ -113,7 +113,7 @@ describe('useAssetFilterOptions', () => {
|
|||||||
createAssetWithSpecificBaseModel('sdxl')
|
createAssetWithSpecificBaseModel('sdxl')
|
||||||
]
|
]
|
||||||
|
|
||||||
const { availableBaseModels } = useAssetFilterOptions(assets)
|
const { availableBaseModels } = useAssetFilterOptions(() => assets)
|
||||||
|
|
||||||
expect(availableBaseModels.value).toEqual([
|
expect(availableBaseModels.value).toEqual([
|
||||||
{ name: 'sdxl', value: 'sdxl' }
|
{ name: 'sdxl', value: 'sdxl' }
|
||||||
@@ -121,7 +121,7 @@ describe('useAssetFilterOptions', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('handles empty asset list', () => {
|
it('handles empty asset list', () => {
|
||||||
const { availableBaseModels } = useAssetFilterOptions([])
|
const { availableBaseModels } = useAssetFilterOptions(() => [])
|
||||||
|
|
||||||
expect(availableBaseModels.value).toEqual([])
|
expect(availableBaseModels.value).toEqual([])
|
||||||
})
|
})
|
||||||
@@ -132,9 +132,8 @@ describe('useAssetFilterOptions', () => {
|
|||||||
const assets = [createAssetWithSpecificExtension('safetensors')]
|
const assets = [createAssetWithSpecificExtension('safetensors')]
|
||||||
|
|
||||||
const { availableFileFormats, availableBaseModels } =
|
const { availableFileFormats, availableBaseModels } =
|
||||||
useAssetFilterOptions(assets)
|
useAssetFilterOptions(() => assets)
|
||||||
|
|
||||||
// These should be computed refs
|
|
||||||
expect(availableFileFormats.value).toBeDefined()
|
expect(availableFileFormats.value).toBeDefined()
|
||||||
expect(availableBaseModels.value).toBeDefined()
|
expect(availableBaseModels.value).toBeDefined()
|
||||||
expect(typeof availableFileFormats.value).toBe('object')
|
expect(typeof availableFileFormats.value).toBe('object')
|
||||||
|
|||||||
403
tests-ui/platform/assets/utils/createModelNodeFromAsset.test.ts
Normal file
403
tests-ui/platform/assets/utils/createModelNodeFromAsset.test.ts
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { type Raw, markRaw } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type LGraphNode,
|
||||||
|
LiteGraph,
|
||||||
|
type Subgraph
|
||||||
|
} from '@/lib/litegraph/src/litegraph'
|
||||||
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
|
||||||
|
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||||
|
import { app } from '@/scripts/app'
|
||||||
|
import { useLitegraphService } from '@/services/litegraphService'
|
||||||
|
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/scripts/api', () => ({
|
||||||
|
api: {
|
||||||
|
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
vi.mock('@/stores/modelToNodeStore', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@/stores/modelToNodeStore')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useModelToNodeStore: vi.fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
vi.mock(
|
||||||
|
'@/platform/workflow/management/stores/workflowStore',
|
||||||
|
async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<
|
||||||
|
typeof import('@/platform/workflow/management/stores/workflowStore')
|
||||||
|
>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useWorkflowStore: vi.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
vi.mock('@/services/litegraphService', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@/services/litegraphService')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useLitegraphService: vi.fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@/lib/litegraph/src/litegraph')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
LiteGraph: {
|
||||||
|
...actual.LiteGraph,
|
||||||
|
createNode: vi.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
vi.mock('@/scripts/app', () => ({
|
||||||
|
app: {
|
||||||
|
canvas: {
|
||||||
|
graph: {
|
||||||
|
add: vi.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||||
|
return {
|
||||||
|
id: 'asset-123',
|
||||||
|
name: 'test-model.safetensors',
|
||||||
|
size: 1024,
|
||||||
|
created_at: '2025-10-01T00:00:00Z',
|
||||||
|
tags: ['models', 'checkpoints'],
|
||||||
|
user_metadata: {
|
||||||
|
filename: 'models/checkpoints/test-model.safetensors'
|
||||||
|
},
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function createMockNode(overrides?: {
|
||||||
|
widgetName?: string
|
||||||
|
widgetValue?: string
|
||||||
|
hasWidgets?: boolean
|
||||||
|
}): Promise<LGraphNode> {
|
||||||
|
const {
|
||||||
|
widgetName = 'ckpt_name',
|
||||||
|
widgetValue = '',
|
||||||
|
hasWidgets = true
|
||||||
|
} = overrides || {}
|
||||||
|
|
||||||
|
const { LGraphNode: ActualLGraphNode } = await vi.importActual<
|
||||||
|
typeof import('@/lib/litegraph/src/litegraph')
|
||||||
|
>('@/lib/litegraph/src/litegraph')
|
||||||
|
|
||||||
|
if (!hasWidgets) {
|
||||||
|
return Object.create(ActualLGraphNode.prototype)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Widget = NonNullable<LGraphNode['widgets']>[number]
|
||||||
|
const widget: Pick<Widget, 'name' | 'value' | 'type' | 'options' | 'y'> = {
|
||||||
|
name: widgetName,
|
||||||
|
value: widgetValue,
|
||||||
|
type: 'string',
|
||||||
|
options: {},
|
||||||
|
y: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.create(ActualLGraphNode.prototype, {
|
||||||
|
widgets: { value: [widget], writable: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function createMockNodeProvider() {
|
||||||
|
return {
|
||||||
|
nodeDef: {
|
||||||
|
name: 'CheckpointLoaderSimple',
|
||||||
|
display_name: 'Load Checkpoint'
|
||||||
|
},
|
||||||
|
key: 'ckpt_name'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Configures all mocked dependencies with sensible defaults.
|
||||||
|
* Uses semantic parameters for clearer test intent.
|
||||||
|
* For error paths or edge cases, pass null values or specific overrides.
|
||||||
|
*/
|
||||||
|
async function setupMocks(
|
||||||
|
overrides: {
|
||||||
|
nodeProvider?: ReturnType<typeof createMockNodeProvider> | null
|
||||||
|
canvasCenter?: [number, number]
|
||||||
|
activeSubgraph?: Raw<Subgraph>
|
||||||
|
createdNode?: Awaited<ReturnType<typeof createMockNode>> | null
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
nodeProvider = createMockNodeProvider(),
|
||||||
|
canvasCenter = [100, 200],
|
||||||
|
activeSubgraph = undefined,
|
||||||
|
createdNode = await createMockNode()
|
||||||
|
} = overrides
|
||||||
|
|
||||||
|
vi.mocked(useModelToNodeStore).mockReturnValue({
|
||||||
|
...useModelToNodeStore(),
|
||||||
|
getNodeProvider: vi.fn().mockReturnValue(nodeProvider)
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(useLitegraphService).mockReturnValue({
|
||||||
|
...useLitegraphService(),
|
||||||
|
getCanvasCenter: vi.fn().mockReturnValue(canvasCenter)
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mocked(useWorkflowStore).mockReturnValue({
|
||||||
|
...useWorkflowStore(),
|
||||||
|
activeSubgraph,
|
||||||
|
isSubgraphActive: !!activeSubgraph
|
||||||
|
})
|
||||||
|
vi.mocked(LiteGraph.createNode).mockReturnValue(createdNode)
|
||||||
|
}
|
||||||
|
describe('createModelNodeFromAsset', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
describe('when creating nodes from valid assets', () => {
|
||||||
|
it('should create the appropriate loader node for the asset category', async () => {
|
||||||
|
const asset = createMockAsset()
|
||||||
|
await setupMocks()
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(
|
||||||
|
vi.mocked(useModelToNodeStore)().getNodeProvider
|
||||||
|
).toHaveBeenCalledWith('checkpoints')
|
||||||
|
expect(LiteGraph.createNode).toHaveBeenCalledWith(
|
||||||
|
'CheckpointLoaderSimple',
|
||||||
|
'Load Checkpoint',
|
||||||
|
{ pos: [100, 200] }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('should place node at canvas center by default', async () => {
|
||||||
|
const asset = createMockAsset()
|
||||||
|
await setupMocks({
|
||||||
|
canvasCenter: [150, 250]
|
||||||
|
})
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(
|
||||||
|
vi.mocked(useLitegraphService)().getCanvasCenter
|
||||||
|
).toHaveBeenCalled()
|
||||||
|
expect(LiteGraph.createNode).toHaveBeenCalledWith(
|
||||||
|
'CheckpointLoaderSimple',
|
||||||
|
'Load Checkpoint',
|
||||||
|
{ pos: [150, 250] }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('should place node at specified position when position is provided', async () => {
|
||||||
|
const asset = createMockAsset()
|
||||||
|
await setupMocks()
|
||||||
|
const result = createModelNodeFromAsset(asset, { position: [300, 400] })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(
|
||||||
|
vi.mocked(useLitegraphService)().getCanvasCenter
|
||||||
|
).not.toHaveBeenCalled()
|
||||||
|
expect(LiteGraph.createNode).toHaveBeenCalledWith(
|
||||||
|
'CheckpointLoaderSimple',
|
||||||
|
'Load Checkpoint',
|
||||||
|
{ pos: [300, 400] }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('should populate the loader widget with the asset file path', async () => {
|
||||||
|
const asset = createMockAsset()
|
||||||
|
const mockNode = await createMockNode()
|
||||||
|
await setupMocks({ createdNode: mockNode })
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(mockNode.widgets?.[0].value).toBe(
|
||||||
|
'models/checkpoints/test-model.safetensors'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
it('should add node to root graph when no subgraph is active', async () => {
|
||||||
|
const asset = createMockAsset()
|
||||||
|
const mockNode = await createMockNode()
|
||||||
|
await setupMocks({ createdNode: mockNode })
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(vi.mocked(app).canvas.graph!.add).toHaveBeenCalledWith(mockNode)
|
||||||
|
})
|
||||||
|
it('should add node to active subgraph when present', async () => {
|
||||||
|
const asset = createMockAsset()
|
||||||
|
const mockNode = await createMockNode()
|
||||||
|
const { Subgraph } = await vi.importActual<
|
||||||
|
typeof import('@/lib/litegraph/src/litegraph')
|
||||||
|
>('@/lib/litegraph/src/litegraph')
|
||||||
|
const mockSubgraph = markRaw(
|
||||||
|
Object.create(Subgraph.prototype, {
|
||||||
|
add: { value: vi.fn() }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await setupMocks({
|
||||||
|
createdNode: mockNode,
|
||||||
|
activeSubgraph: mockSubgraph
|
||||||
|
})
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(mockSubgraph.add).toHaveBeenCalledWith(mockNode)
|
||||||
|
expect(vi.mocked(app).canvas.graph!.add).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('when asset data is incomplete or invalid', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
case: 'missing user_metadata',
|
||||||
|
overrides: { user_metadata: undefined },
|
||||||
|
expectedCode: 'INVALID_ASSET' as const,
|
||||||
|
errorPattern: /missing required user_metadata/
|
||||||
|
},
|
||||||
|
{
|
||||||
|
case: 'missing filename property',
|
||||||
|
overrides: { user_metadata: {} },
|
||||||
|
expectedCode: 'INVALID_ASSET' as const,
|
||||||
|
errorPattern:
|
||||||
|
/Invalid filename.*expected non-empty string, got undefined/
|
||||||
|
},
|
||||||
|
{
|
||||||
|
case: 'non-string filename',
|
||||||
|
overrides: { user_metadata: { filename: 123 } },
|
||||||
|
expectedCode: 'INVALID_ASSET' as const,
|
||||||
|
errorPattern: /Invalid filename.*expected non-empty string, got number/
|
||||||
|
},
|
||||||
|
{
|
||||||
|
case: 'empty filename',
|
||||||
|
overrides: { user_metadata: { filename: '' } },
|
||||||
|
expectedCode: 'INVALID_ASSET' as const,
|
||||||
|
errorPattern: /Invalid filename.*expected non-empty string/
|
||||||
|
}
|
||||||
|
])(
|
||||||
|
'should fail when asset has $case',
|
||||||
|
({ overrides, expectedCode, errorPattern }) => {
|
||||||
|
const asset = createMockAsset(overrides)
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.code).toBe(expectedCode)
|
||||||
|
expect(result.error.message).toMatch(errorPattern)
|
||||||
|
expect(result.error.assetId).toBe('asset-123')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
case: 'no tags',
|
||||||
|
overrides: { tags: undefined },
|
||||||
|
expectedCode: 'INVALID_ASSET' as const,
|
||||||
|
errorMessage: 'Asset has no tags defined'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
case: 'only excluded tags',
|
||||||
|
overrides: { tags: ['models', 'missing'] },
|
||||||
|
expectedCode: 'INVALID_ASSET' as const,
|
||||||
|
errorMessage: 'Asset has no valid category tag'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
case: 'only the models tag',
|
||||||
|
overrides: { tags: ['models'] },
|
||||||
|
expectedCode: 'INVALID_ASSET' as const,
|
||||||
|
errorMessage: 'Asset has no valid category tag'
|
||||||
|
}
|
||||||
|
])(
|
||||||
|
'should fail when asset has $case',
|
||||||
|
({ overrides, expectedCode, errorMessage }) => {
|
||||||
|
const asset = createMockAsset(overrides)
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.code).toBe(expectedCode)
|
||||||
|
expect(result.error.message).toBe(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
describe('when system resources are unavailable', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
it('should fail when no provider registered for category', async () => {
|
||||||
|
const asset = createMockAsset()
|
||||||
|
await setupMocks({ nodeProvider: null })
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.code).toBe('NO_PROVIDER')
|
||||||
|
expect(result.error.message).toContain('checkpoints')
|
||||||
|
expect(result.error.details?.category).toBe('checkpoints')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('should fail when node creation fails', async () => {
|
||||||
|
const asset = createMockAsset()
|
||||||
|
await setupMocks()
|
||||||
|
vi.mocked(LiteGraph.createNode).mockReturnValue(null)
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.code).toBe('NODE_CREATION_FAILED')
|
||||||
|
expect(result.error.message).toContain('CheckpointLoaderSimple')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('should fail when widget is missing from node', async () => {
|
||||||
|
const asset = createMockAsset()
|
||||||
|
const mockNode = await createMockNode({ widgetName: 'wrong_widget' })
|
||||||
|
await setupMocks({ createdNode: mockNode })
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.code).toBe('MISSING_WIDGET')
|
||||||
|
expect(result.error.message).toContain('ckpt_name')
|
||||||
|
expect(result.error.message).toContain('CheckpointLoaderSimple')
|
||||||
|
expect(result.error.details?.widgetName).toBe('ckpt_name')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('should fail when node has no widgets array', async () => {
|
||||||
|
const asset = createMockAsset()
|
||||||
|
const mockNode = await createMockNode({ hasWidgets: false })
|
||||||
|
await setupMocks({ createdNode: mockNode })
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.code).toBe('MISSING_WIDGET')
|
||||||
|
expect(result.error.message).toContain('ckpt_name not found')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('should not add node to graph when widget validation fails', async () => {
|
||||||
|
const asset = createMockAsset()
|
||||||
|
const mockNode = await createMockNode({ hasWidgets: false })
|
||||||
|
await setupMocks({ createdNode: mockNode })
|
||||||
|
createModelNodeFromAsset(asset)
|
||||||
|
expect(vi.mocked(app).canvas.graph!.add).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
describe('when graph is null', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
|
vi.mocked(app).canvas.graph = null
|
||||||
|
})
|
||||||
|
it('should fail when no graph is available', async () => {
|
||||||
|
const asset = createMockAsset()
|
||||||
|
const mockNode = await createMockNode()
|
||||||
|
await setupMocks({ createdNode: mockNode })
|
||||||
|
const result = createModelNodeFromAsset(asset)
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) {
|
||||||
|
expect(result.error.code).toBe('NO_GRAPH')
|
||||||
|
expect(result.error.message).toBe('No active graph available')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -136,36 +136,7 @@ describe('useComboWidget', () => {
|
|||||||
expect(widget).toBe(mockWidget)
|
expect(widget).toBe(mockWidget)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should create normal combo widget when widget is not eligible for asset browser', () => {
|
it('should create asset browser widget when API enabled', () => {
|
||||||
mockSettingStoreGet.mockReturnValue(true)
|
|
||||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
|
|
||||||
|
|
||||||
const constructor = useComboWidget()
|
|
||||||
const mockWidget = createMockWidget()
|
|
||||||
const mockNode = createMockNode()
|
|
||||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
|
||||||
const inputSpec = createMockInputSpec({
|
|
||||||
name: 'not_eligible_widget',
|
|
||||||
options: ['option1', 'option2']
|
|
||||||
})
|
|
||||||
|
|
||||||
const widget = constructor(mockNode, inputSpec)
|
|
||||||
|
|
||||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
|
||||||
'combo',
|
|
||||||
'not_eligible_widget',
|
|
||||||
'option1',
|
|
||||||
expect.any(Function),
|
|
||||||
{ values: ['option1', 'option2'] }
|
|
||||||
)
|
|
||||||
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
|
|
||||||
'not_eligible_widget',
|
|
||||||
'TestNode'
|
|
||||||
)
|
|
||||||
expect(widget).toBe(mockWidget)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should create asset browser widget when API enabled and widget eligible', () => {
|
|
||||||
mockSettingStoreGet.mockReturnValue(true)
|
mockSettingStoreGet.mockReturnValue(true)
|
||||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||||
|
|
||||||
@@ -192,13 +163,12 @@ describe('useComboWidget', () => {
|
|||||||
)
|
)
|
||||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||||
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
|
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
|
||||||
'ckpt_name',
|
|
||||||
'CheckpointLoaderSimple'
|
'CheckpointLoaderSimple'
|
||||||
)
|
)
|
||||||
expect(widget).toBe(mockWidget)
|
expect(widget).toBe(mockWidget)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should create asset browser widget with options when API enabled and widget eligible', () => {
|
it('should create asset browser widget with options when API enabled', () => {
|
||||||
mockSettingStoreGet.mockReturnValue(true)
|
mockSettingStoreGet.mockReturnValue(true)
|
||||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||||
|
|
||||||
@@ -224,10 +194,6 @@ describe('useComboWidget', () => {
|
|||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
)
|
)
|
||||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||||
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
|
|
||||||
'ckpt_name',
|
|
||||||
'CheckpointLoaderSimple'
|
|
||||||
)
|
|
||||||
expect(widget).toBe(mockWidget)
|
expect(widget).toBe(mockWidget)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -258,10 +224,6 @@ describe('useComboWidget', () => {
|
|||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
)
|
)
|
||||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||||
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
|
|
||||||
'ckpt_name',
|
|
||||||
'CheckpointLoaderSimple'
|
|
||||||
)
|
|
||||||
expect(widget).toBe(mockWidget)
|
expect(widget).toBe(mockWidget)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -291,10 +253,6 @@ describe('useComboWidget', () => {
|
|||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
)
|
)
|
||||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||||
expect(vi.mocked(assetService.isAssetBrowserEligible)).toHaveBeenCalledWith(
|
|
||||||
'ckpt_name',
|
|
||||||
'CheckpointLoaderSimple'
|
|
||||||
)
|
|
||||||
expect(widget).toBe(mockWidget)
|
expect(widget).toBe(mockWidget)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
|||||||
import { assetService } from '@/platform/assets/services/assetService'
|
import { assetService } from '@/platform/assets/services/assetService'
|
||||||
import { api } from '@/scripts/api'
|
import { api } from '@/scripts/api'
|
||||||
|
|
||||||
|
vi.mock('@/scripts/api', () => ({
|
||||||
|
api: {
|
||||||
|
fetchApi: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
const mockGetCategoryForNodeType = vi.fn()
|
const mockGetCategoryForNodeType = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/stores/modelToNodeStore', () => ({
|
vi.mock('@/stores/modelToNodeStore', () => ({
|
||||||
@@ -108,7 +116,9 @@ describe('assetService', () => {
|
|||||||
|
|
||||||
const result = await assetService.getAssetModelFolders()
|
const result = await assetService.getAssetModelFolders()
|
||||||
|
|
||||||
expect(api.fetchApi).toHaveBeenCalledWith('/assets?include_tags=models')
|
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||||
|
'/assets?include_tags=models&limit=300'
|
||||||
|
)
|
||||||
expect(result).toHaveLength(2)
|
expect(result).toHaveLength(2)
|
||||||
|
|
||||||
const folderNames = result.map((f) => f.name)
|
const folderNames = result.map((f) => f.name)
|
||||||
@@ -153,7 +163,7 @@ describe('assetService', () => {
|
|||||||
const result = await assetService.getAssetModels('checkpoints')
|
const result = await assetService.getAssetModels('checkpoints')
|
||||||
|
|
||||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||||
'/assets?include_tags=models,checkpoints'
|
'/assets?include_tags=models,checkpoints&limit=300'
|
||||||
)
|
)
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 })
|
expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 })
|
||||||
@@ -181,41 +191,18 @@ describe('assetService', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('isAssetBrowserEligible', () => {
|
describe('isAssetBrowserEligible', () => {
|
||||||
it('should return true for eligible widget names with registered node types', () => {
|
it('should return true for registered node types', () => {
|
||||||
expect(
|
expect(
|
||||||
assetService.isAssetBrowserEligible(
|
assetService.isAssetBrowserEligible('CheckpointLoaderSimple')
|
||||||
'ckpt_name',
|
|
||||||
'CheckpointLoaderSimple'
|
|
||||||
)
|
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(
|
expect(assetService.isAssetBrowserEligible('LoraLoader')).toBe(true)
|
||||||
assetService.isAssetBrowserEligible('lora_name', 'LoraLoader')
|
expect(assetService.isAssetBrowserEligible('VAELoader')).toBe(true)
|
||||||
).toBe(true)
|
|
||||||
expect(assetService.isAssetBrowserEligible('vae_name', 'VAELoader')).toBe(
|
|
||||||
true
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return false for non-eligible widget names', () => {
|
it('should return false for unregistered node types', () => {
|
||||||
expect(assetService.isAssetBrowserEligible('seed', 'TestNode')).toBe(
|
expect(assetService.isAssetBrowserEligible('UnknownNode')).toBe(false)
|
||||||
false
|
expect(assetService.isAssetBrowserEligible('NotRegistered')).toBe(false)
|
||||||
)
|
expect(assetService.isAssetBrowserEligible('')).toBe(false)
|
||||||
expect(assetService.isAssetBrowserEligible('steps', 'TestNode')).toBe(
|
|
||||||
false
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
assetService.isAssetBrowserEligible('sampler_name', 'TestNode')
|
|
||||||
).toBe(false)
|
|
||||||
expect(assetService.isAssetBrowserEligible('', 'TestNode')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return false for eligible widget names with unregistered node types', () => {
|
|
||||||
expect(
|
|
||||||
assetService.isAssetBrowserEligible('ckpt_name', 'UnknownNode')
|
|
||||||
).toBe(false)
|
|
||||||
expect(
|
|
||||||
assetService.isAssetBrowserEligible('lora_name', 'UnknownNode')
|
|
||||||
).toBe(false)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -249,7 +236,7 @@ describe('assetService', () => {
|
|||||||
|
|
||||||
// Verify API call includes correct category
|
// Verify API call includes correct category
|
||||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||||
'/assets?include_tags=models,checkpoints'
|
'/assets?include_tags=models,checkpoints&limit=300'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -301,4 +288,72 @@ describe('assetService', () => {
|
|||||||
expect(result).toEqual(vaeAssets)
|
expect(result).toEqual(vaeAssets)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getAssetsByTag', () => {
|
||||||
|
it('should fetch assets with correct tag query parameter', async () => {
|
||||||
|
const testAssets = [MOCK_ASSETS.checkpoints, MOCK_ASSETS.loras]
|
||||||
|
mockApiResponse(testAssets)
|
||||||
|
|
||||||
|
const result = await assetService.getAssetsByTag('models')
|
||||||
|
|
||||||
|
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||||
|
'/assets?include_tags=models&limit=300'
|
||||||
|
)
|
||||||
|
expect(result).toEqual(testAssets)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should filter out assets with missing tag', async () => {
|
||||||
|
const testAssets = [
|
||||||
|
MOCK_ASSETS.checkpoints,
|
||||||
|
createTestAsset({
|
||||||
|
id: 'uuid-missing',
|
||||||
|
name: 'missing.safetensors',
|
||||||
|
tags: ['models', 'checkpoints', 'missing']
|
||||||
|
}),
|
||||||
|
MOCK_ASSETS.loras
|
||||||
|
]
|
||||||
|
mockApiResponse(testAssets)
|
||||||
|
|
||||||
|
const result = await assetService.getAssetsByTag('models')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result).toEqual([MOCK_ASSETS.checkpoints, MOCK_ASSETS.loras])
|
||||||
|
expect(result.some((a) => a.id === 'uuid-missing')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty array on API error', async () => {
|
||||||
|
mockApiError(500)
|
||||||
|
|
||||||
|
await expect(assetService.getAssetsByTag('models')).rejects.toThrow(
|
||||||
|
'Unable to load assets for tag models: Server returned 500. Please try again.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return empty array for empty response', async () => {
|
||||||
|
mockApiResponse([])
|
||||||
|
|
||||||
|
const result = await assetService.getAssetsByTag('nonexistent')
|
||||||
|
|
||||||
|
expect(result).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return AssetItem[] with full metadata', async () => {
|
||||||
|
const fullAsset = createTestAsset({
|
||||||
|
id: 'test-full',
|
||||||
|
name: 'full-model.safetensors',
|
||||||
|
asset_hash: 'blake3:full123',
|
||||||
|
size: 999999,
|
||||||
|
tags: ['models', 'checkpoints'],
|
||||||
|
user_metadata: { filename: 'models/checkpoints/full-model.safetensors' }
|
||||||
|
})
|
||||||
|
mockApiResponse([fullAsset])
|
||||||
|
|
||||||
|
const result = await assetService.getAssetsByTag('models')
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toEqual(fullAsset)
|
||||||
|
expect(result[0]).toHaveProperty('asset_hash', 'blake3:full123')
|
||||||
|
expect(result[0]).toHaveProperty('user_metadata')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user