Asset Browser Modal Component (#5607)

* [ci] ignore playwright mcp directory

* [feat] add AssetBrowserModal

And all related sub components

* [feat] reactive filter functions

* [ci] clean up storybook config

* [feat] add sematic AssetCard

* [fix] i love lucide

* [fix] AssetCard layout issues

* [fix] add AssetBadge type

* [fix] simplify useAssetBrowser

* [fix] modal layout

* [fix] simplify useAssetBrowserDialog

* [fix] add tailwind back to storybook

* [fix] better reponsive layout

* [fix] missed i18n string

* [fix] missing i18n translations

* [fix] remove erroneous prevent on keyboard.space

* [feat] add asset metadata validation utilities

* [fix] remove erroneous test code

* [fix] remove forced min and max width on AssetCard

* [fix] import statement nits
This commit is contained in:
Arjan Singh
2025-09-17 16:17:09 -07:00
committed by GitHub
parent 08220d50d9
commit dfa1cbba4f
27 changed files with 2636 additions and 61 deletions

4
.gitignore vendored
View File

@@ -78,8 +78,8 @@ vite.config.mts.timestamp-*.mjs
*storybook.log
storybook-static
# MCP Servers
.playwright-mcp/*
.nx/cache
.nx/workspace-data

View File

@@ -15,21 +15,32 @@ const config: StorybookConfig = {
async viteFinal(config) {
// Use dynamic import to avoid CJS deprecation warning
const { mergeConfig } = await import('vite')
const { default: tailwindcss } = await import('@tailwindcss/vite')
// Filter out any plugins that might generate import maps
if (config.plugins) {
config.plugins = config.plugins.filter((plugin: any) => {
if (plugin && plugin.name && plugin.name.includes('import-map')) {
return false
}
return true
})
config.plugins = config.plugins
// Type guard: ensure we have valid plugin objects with names
.filter(
(plugin): plugin is NonNullable<typeof plugin> & { name: string } => {
return (
plugin !== null &&
plugin !== undefined &&
typeof plugin === 'object' &&
'name' in plugin &&
typeof plugin.name === 'string'
)
}
)
// Business logic: filter out import-map plugins
.filter((plugin) => !plugin.name.includes('import-map'))
}
return mergeConfig(config, {
// Replace plugins entirely to avoid inheritance issues
plugins: [
// Only include plugins we explicitly need for Storybook
tailwindcss(),
Icons({
compiler: 'vue3',
customCollections: {

View File

@@ -1,7 +1,7 @@
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
import { setup } from '@storybook/vue3'
import type { Preview } from '@storybook/vue3-vite'
import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
import { createPinia } from 'pinia'
import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config'
@@ -9,11 +9,9 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip'
import '../src/assets/css/style.css'
import { i18n } from '../src/i18n'
import '../src/lib/litegraph/public/css/litegraph.css'
import { useWidgetStore } from '../src/stores/widgetStore'
import { useColorPaletteStore } from '../src/stores/workspace/colorPaletteStore'
import '@/assets/css/style.css'
import { i18n } from '@/i18n'
import '@/lib/litegraph/public/css/litegraph.css'
const ComfyUIPreset = definePreset(Aura, {
semantic: {
@@ -25,13 +23,11 @@ const ComfyUIPreset = definePreset(Aura, {
// Setup Vue app for Storybook
setup((app) => {
app.directive('tooltip', Tooltip)
// Create Pinia instance
const pinia = createPinia()
app.use(pinia)
// Initialize stores
useColorPaletteStore(pinia)
useWidgetStore(pinia)
app.use(i18n)
app.use(PrimeVue, {
theme: {
@@ -50,8 +46,8 @@ setup((app) => {
app.use(ToastService)
})
// Dark theme decorator
export const withTheme = (Story: any, context: any) => {
// Theme and dialog decorator
export const withTheme = (Story: StoryFn, context: StoryContext) => {
const theme = context.globals.theme || 'light'
// Apply theme class to document root
@@ -63,7 +59,7 @@ export const withTheme = (Story: any, context: any) => {
document.body.classList.remove('dark-theme')
}
return Story()
return Story(context.args, context)
}
const preview: Preview = {

View File

@@ -1862,5 +1862,17 @@
"showGroups": "Show Frames/Groups",
"renderBypassState": "Render Bypass State",
"renderErrorState": "Render Error State"
},
"assetBrowser": {
"assets": "Assets",
"browseAssets": "Browse Assets",
"noAssetsFound": "No assets found",
"tryAdjustingFilters": "Try adjusting your search or filters",
"loadingModels": "Loading {type}...",
"connectionError": "Please check your connection and try again",
"noModelsInFolder": "No {type} available in this folder",
"searchAssetsPlaceholder": "Search assets...",
"allModels": "All Models",
"unknown": "Unknown"
}
}

View File

@@ -0,0 +1,42 @@
<template>
<div class="absolute bottom-2 right-2 flex flex-wrap justify-end gap-1">
<span
v-for="badge in badges"
:key="badge.label"
:class="
cn(
'px-2 py-1 rounded text-xs font-medium uppercase tracking-wider text-white',
getBadgeColor(badge.type)
)
"
>
{{ badge.label }}
</span>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
type AssetBadge = {
label: string
type: 'type' | 'base' | 'size'
}
defineProps<{
badges: AssetBadge[]
}>()
function getBadgeColor(type: AssetBadge['type']): string {
switch (type) {
case 'type':
return 'bg-blue-100/90 dark-theme:bg-blue-100/80'
case 'base':
return 'bg-success-100/90 dark-theme:bg-success-100/80'
case 'size':
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
default:
return 'bg-stone-100/90 dark-theme:bg-charcoal-700/80'
}
}
</script>

View File

@@ -0,0 +1,178 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import {
createMockAssets,
mockAssets
} from '@/platform/assets/fixtures/ui-mock-assets'
// Story arguments interface
interface StoryArgs {
nodeType: string
inputName: string
currentValue: string
showLeftPanel?: boolean
}
const meta: Meta<StoryArgs> = {
title: 'Platform/Assets/AssetBrowserModal',
component: AssetBrowserModal,
parameters: {
layout: 'fullscreen'
},
argTypes: {
nodeType: {
control: 'select',
options: ['CheckpointLoaderSimple', 'VAELoader', 'ControlNetLoader'],
description: 'ComfyUI node type for context'
},
inputName: {
control: 'select',
options: ['ckpt_name', 'vae_name', 'control_net_name'],
description: 'Widget input name'
},
currentValue: {
control: 'text',
description: 'Current selected asset value'
},
showLeftPanel: {
control: 'boolean',
description: 'Whether to show the left panel with categories'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
// Modal Layout Stories
export const Default: Story = {
args: {
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: '',
showLeftPanel: false
},
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
console.log('Modal closed')
}
return {
...args,
onAssetSelect,
onClose,
assets: mockAssets
}
},
template: `
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
:show-left-panel="showLeftPanel"
:assets="assets"
@asset-select="onAssetSelect"
@close="onClose"
/>
</div>
`
})
}
// Story demonstrating single asset type (auto-hides left panel)
export const SingleAssetType: Story = {
args: {
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: '',
showLeftPanel: false
},
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
console.log('Modal closed')
}
// Create assets with only one type (checkpoints)
const singleTypeAssets = createMockAssets(15).map((asset) => ({
...asset,
type: 'checkpoint'
}))
return { ...args, onAssetSelect, onClose, assets: singleTypeAssets }
},
template: `
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
:show-left-panel="showLeftPanel"
:assets="assets"
@asset-select="onAssetSelect"
@close="onClose"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Modal with assets of only one type (checkpoint) - left panel auto-hidden.'
}
}
}
}
// Story with left panel explicitly hidden
export const NoLeftPanel: Story = {
args: {
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: '',
showLeftPanel: false
},
render: (args) => ({
components: { AssetBrowserModal },
setup() {
const onAssetSelect = (asset: any) => {
console.log('Selected asset:', asset)
}
const onClose = () => {
console.log('Modal closed')
}
return { ...args, onAssetSelect, onClose, assets: mockAssets }
},
template: `
<div class="flex items-center justify-center min-h-screen bg-stone-200 dark-theme:bg-stone-200 p-4">
<AssetBrowserModal
:node-type="nodeType"
:input-name="inputName"
:show-left-panel="showLeftPanel"
:assets="assets"
@asset-select="onAssetSelect"
@close="onClose"
/>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Modal with left panel explicitly disabled via showLeftPanel=false.'
}
}
}
}

View File

@@ -0,0 +1,95 @@
<template>
<BaseModalLayout
data-component-id="AssetBrowserModal"
class="size-full max-h-full max-w-full min-w-0"
:content-title="contentTitle"
@close="handleClose"
>
<template v-if="shouldShowLeftPanel" #leftPanel>
<LeftSidePanel
v-model="selectedCategory"
data-component-id="AssetBrowserModal-LeftSidePanel"
:nav-items="availableCategories"
>
<template #header-icon>
<i-lucide:folder class="size-4" />
</template>
<template #header-title>{{ $t('assetBrowser.browseAssets') }}</template>
</LeftSidePanel>
</template>
<template #header>
<SearchBox
v-model="searchQuery"
size="lg"
:placeholder="$t('assetBrowser.searchAssetsPlaceholder')"
class="max-w-96"
/>
</template>
<template #content>
<AssetGrid
:assets="filteredAssets"
@asset-select="handleAssetSelectAndEmit"
/>
</template>
</BaseModalLayout>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SearchBox from '@/components/input/SearchBox.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
const props = defineProps<{
nodeType?: string
inputName?: string
onSelect?: (assetPath: string) => void
onClose?: () => void
showLeftPanel?: boolean
assets?: AssetItem[]
}>()
const emit = defineEmits<{
'asset-select': [asset: AssetDisplayItem]
close: []
}>()
// Use AssetBrowser composable for all business logic
const {
searchQuery,
selectedCategory,
availableCategories,
contentTitle,
filteredAssets,
selectAsset
} = useAssetBrowser(props.assets)
// Dialog controls panel visibility via prop
const shouldShowLeftPanel = computed(() => {
return props.showLeftPanel ?? true
})
// Handle close button - call both the prop callback and emit the event
const handleClose = () => {
props.onClose?.()
emit('close')
}
// Handle asset selection and emit to parent
const handleAssetSelectAndEmit = (asset: AssetDisplayItem) => {
selectAsset(asset) // This logs the selection for dev mode
emit('asset-select', asset) // Emit the full asset object
// Call prop callback if provided
if (props.onSelect) {
props.onSelect(asset.name) // Use asset name as the asset path
}
}
</script>

View File

@@ -0,0 +1,182 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
// Use the first mock asset as base and transform it to display format
const baseAsset = mockAssets[0]
const createAssetData = (
overrides: Partial<AssetDisplayItem> = {}
): AssetDisplayItem => ({
...baseAsset,
description:
'High-quality realistic images with perfect detail and natural lighting effects for professional photography',
formattedSize: '2.1 GB',
badges: [
{ label: 'checkpoints', type: 'type' },
{ label: '2.1 GB', type: 'size' }
],
stats: {
formattedDate: '3/15/25',
downloadCount: '1.8k',
stars: '4.2k'
},
...overrides
})
const meta: Meta<typeof AssetCard> = {
title: 'Platform/Assets/AssetCard',
component: AssetCard,
parameters: {
layout: 'centered'
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Interactive: Story = {
args: {
asset: createAssetData(),
interactive: true
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
})
],
parameters: {
docs: {
description: {
story:
'Default AssetCard with complete data including badges and all stats.'
}
}
}
}
export const NonInteractive: Story = {
args: {
asset: createAssetData(),
interactive: false
},
decorators: [
() => ({
template:
'<div class="p-8 bg-gray-50 dark-theme:bg-gray-900 max-w-96"><story /></div>'
})
],
parameters: {
docs: {
description: {
story:
'AssetCard in non-interactive mode - renders as div without button semantics.'
}
}
}
}
export const EdgeCases: Story = {
render: () => ({
components: { AssetCard },
setup() {
const edgeCases = [
// Default case for comparison
createAssetData({
name: 'Complete Data',
description: 'Asset with all data present for comparison'
}),
// No badges
createAssetData({
id: 'no-badges',
name: 'No Badges',
description: 'Testing graceful handling when badges are not provided',
badges: []
}),
// No stars
createAssetData({
id: 'no-stars',
name: 'No Stars',
description: 'Testing missing stars data gracefully',
stats: {
downloadCount: '1.8k',
formattedDate: '3/15/25'
}
}),
// No downloads
createAssetData({
id: 'no-downloads',
name: 'No Downloads',
description: 'Testing missing downloads data gracefully',
stats: {
stars: '4.2k',
formattedDate: '3/15/25'
}
}),
// No date
createAssetData({
id: 'no-date',
name: 'No Date',
description: 'Testing missing date data gracefully',
stats: {
stars: '4.2k',
downloadCount: '1.8k'
}
}),
// No stats at all
createAssetData({
id: 'no-stats',
name: 'No Stats',
description: 'Testing when all stats are missing',
stats: {}
}),
// Long description
createAssetData({
id: 'long-desc',
name: 'Long Description',
description:
'This is a very long description that should demonstrate how the component handles text overflow and truncation with ellipsis. The description continues with even more content to ensure we test the 2-line clamp behavior properly and see how it renders when there is significantly more text than can fit in the allocated space.'
}),
// Minimal data
createAssetData({
id: 'minimal',
name: 'Minimal',
description: 'Basic model',
tags: ['models'],
badges: [],
stats: {}
})
]
return { edgeCases }
},
template: `
<div class="grid grid-cols-4 gap-6 p-8 bg-gray-50 dark-theme:bg-gray-900">
<AssetCard
v-for="asset in edgeCases"
:key="asset.id"
:asset="asset"
:interactive="true"
@select="(asset) => console.log('Selected:', asset)"
/>
</div>
`
}),
parameters: {
layout: 'fullscreen',
docs: {
description: {
story:
'All AssetCard edge cases in a grid layout to test graceful handling of missing data, badges, stats, and long descriptions.'
}
}
}
}

View File

@@ -0,0 +1,111 @@
<template>
<component
:is="interactive ? 'button' : 'div'"
data-component-id="AssetCard"
:data-asset-id="asset.id"
v-bind="elementProps"
:class="
cn(
// Base layout and container styles (always applied)
'rounded-xl overflow-hidden transition-all duration-200',
// Button-specific styles
interactive && [
'appearance-none bg-transparent p-0 m-0 font-inherit text-inherit outline-none cursor-pointer text-left',
'bg-ivory-100 border border-gray-300 dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600',
'hover:transform hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/10 hover:border-gray-400',
'dark-theme:hover:shadow-lg dark-theme:hover:shadow-black/30 dark-theme:hover:border-charcoal-700',
'focus:outline-none focus:ring-2 focus:ring-blue-500 dark-theme:focus:ring-blue-400'
],
// Div-specific styles
!interactive && [
'bg-ivory-100 border border-gray-300',
'dark-theme:bg-charcoal-400 dark-theme:border-charcoal-600'
]
)
"
@click="interactive && $emit('select', asset)"
@keydown.enter="interactive && $emit('select', asset)"
>
<div class="relative w-full aspect-square overflow-hidden">
<div
class="w-full h-full bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-600 flex items-center justify-center"
></div>
<AssetBadgeGroup :badges="asset.badges" />
</div>
<div class="p-4 h-32 flex flex-col justify-between">
<div>
<h3
:class="
cn(
'mb-2 m-0 text-base font-semibold overflow-hidden text-ellipsis whitespace-nowrap',
'text-slate-800',
'dark-theme:text-white'
)
"
>
{{ asset.name }}
</h3>
<p
:class="
cn(
'm-0 text-sm leading-6 overflow-hidden [-webkit-box-orient:vertical] [-webkit-line-clamp:2] [display:-webkit-box]',
'text-stone-300',
'dark-theme:text-stone-200'
)
"
:title="asset.description"
>
{{ asset.description }}
</p>
</div>
<div
:class="
cn(
'flex gap-4 text-xs',
'text-stone-400',
'dark-theme:text-stone-300'
)
"
>
<span v-if="asset.stats.stars" class="flex items-center gap-1">
<i-lucide:star class="size-3" />
{{ asset.stats.stars }}
</span>
<span v-if="asset.stats.downloadCount" class="flex items-center gap-1">
<i-lucide:download class="size-3" />
{{ asset.stats.downloadCount }}
</span>
<span v-if="asset.stats.formattedDate" class="flex items-center gap-1">
<i-lucide:clock class="size-3" />
{{ asset.stats.formattedDate }}
</span>
</div>
</div>
</component>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssetBadgeGroup from '@/platform/assets/components/AssetBadgeGroup.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
asset: AssetDisplayItem
interactive?: boolean
}>()
const elementProps = computed(() =>
props.interactive
? {
type: 'button',
'aria-label': `Select asset ${props.asset.name}`
}
: {}
)
defineEmits<{
select: [asset: AssetDisplayItem]
}>()
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div :class="containerClasses" data-component-id="asset-filter-bar">
<div :class="leftSideClasses" data-component-id="asset-filter-bar-left">
<MultiSelect
v-model="fileFormats"
label="File formats"
:options="fileFormatOptions"
:class="selectClasses"
data-component-id="asset-filter-file-formats"
@update:model-value="handleFilterChange"
/>
<MultiSelect
v-model="baseModels"
label="Base models"
:options="baseModelOptions"
:class="selectClasses"
data-component-id="asset-filter-base-models"
@update:model-value="handleFilterChange"
/>
</div>
<div :class="rightSideClasses" data-component-id="asset-filter-bar-right">
<SingleSelect
v-model="sortBy"
label="Sort by"
:options="sortOptions"
:class="selectClasses"
data-component-id="asset-filter-sort"
@update:model-value="handleFilterChange"
>
<template #icon>
<i-lucide:arrow-up-down class="size-3" />
</template>
</SingleSelect>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { SelectOption } from '@/components/input/types'
import { cn } from '@/utils/tailwindUtil'
export interface FilterState {
fileFormats: string[]
baseModels: string[]
sortBy: string
}
const fileFormats = ref<SelectOption[]>([])
const baseModels = ref<SelectOption[]>([])
const sortBy = ref('name-asc')
// TODO: Make fileFormatOptions configurable via props or assetService
// Should support dynamic file formats based on available assets or server capabilities
const fileFormatOptions = [
{ name: '.ckpt', value: 'ckpt' },
{ name: '.safetensors', value: 'safetensors' },
{ name: '.pt', value: 'pt' }
]
// TODO: Make baseModelOptions configurable via props or assetService
// Should support dynamic base models based on available assets or server detection
const baseModelOptions = [
{ name: 'SD 1.5', value: 'sd15' },
{ name: 'SD XL', value: 'sdxl' },
{ name: 'SD 3.5', value: 'sd35' }
]
// TODO: Make sortOptions configurable via props
// Different asset types might need different sorting options
const sortOptions = [
{ name: 'A-Z', value: 'name-asc' },
{ name: 'Z-A', value: 'name-desc' },
{ name: 'Recent', value: 'recent' },
{ name: 'Popular', value: 'popular' }
]
const emit = defineEmits<{
filterChange: [filters: FilterState]
}>()
const containerClasses = cn(
'flex gap-4 items-center justify-between',
'px-6 pt-2 pb-6'
)
const leftSideClasses = cn('flex gap-4 items-center')
const rightSideClasses = cn('flex items-center')
const selectClasses = cn('min-w-32')
function handleFilterChange() {
emit('filterChange', {
fileFormats: fileFormats.value.map((option: SelectOption) => option.value),
baseModels: baseModels.value.map((option: SelectOption) => option.value),
sortBy: sortBy.value
})
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div
data-component-id="AssetGrid"
:style="gridStyle"
role="grid"
aria-label="Asset collection"
:aria-rowcount="-1"
:aria-colcount="-1"
:aria-setsize="assets.length"
>
<AssetCard
v-for="asset in assets"
:key="asset.id"
:asset="asset"
:interactive="true"
role="gridcell"
@select="$emit('assetSelect', $event)"
/>
<!-- Empty state -->
<div
v-if="assets.length === 0"
:class="
cn(
'col-span-full flex flex-col items-center justify-center py-16',
'text-stone-300 dark-theme:text-stone-200'
)
"
>
<i-lucide:search class="size-10 mb-4" />
<h3 class="text-lg font-medium mb-2">
{{ $t('assetBrowser.noAssetsFound') }}
</h3>
<p class="text-sm">{{ $t('assetBrowser.tryAdjustingFilters') }}</p>
</div>
<!-- Loading state -->
<div
v-if="loading"
class="col-span-full flex items-center justify-center py-16"
>
<i-lucide:loader
:class="
cn('size-6 animate-spin', 'text-stone-300 dark-theme:text-stone-200')
"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AssetCard from '@/platform/assets/components/AssetCard.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { createGridStyle } from '@/utils/gridUtil'
import { cn } from '@/utils/tailwindUtil'
defineProps<{
assets: AssetDisplayItem[]
loading?: boolean
}>()
defineEmits<{
assetSelect: [asset: AssetDisplayItem]
}>()
// Use same grid style as BaseModalLayout
const gridStyle = computed(() => createGridStyle())
</script>

View File

@@ -0,0 +1,188 @@
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModel,
getAssetDescription
} from '@/platform/assets/utils/assetMetadataUtils'
import { formatSize } from '@/utils/formatUtil'
type AssetBadge = {
label: string
type: 'type' | 'base' | 'size'
}
// Display properties for transformed assets
export interface AssetDisplayItem extends AssetItem {
description: string
formattedSize: string
badges: AssetBadge[]
stats: {
formattedDate?: string
downloadCount?: string
stars?: string
}
}
/**
* Asset Browser composable
* Manages search, filtering, asset transformation and selection logic
*/
export function useAssetBrowser(assets: AssetItem[] = []) {
// State
const searchQuery = ref('')
const selectedCategory = ref('all')
const sortBy = ref('name')
// Transform API asset to display asset
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
// Extract description from metadata or create from tags
const typeTag = asset.tags.find((tag) => tag !== 'models')
const description =
getAssetDescription(asset) ||
`${typeTag || t('assetBrowser.unknown')} model`
// Format file size
const formattedSize = formatSize(asset.size)
// Create badges from tags and metadata
const badges: AssetBadge[] = []
// Type badge from non-root tag
if (typeTag) {
badges.push({ label: typeTag, type: 'type' })
}
// Base model badge from metadata
const baseModel = getAssetBaseModel(asset)
if (baseModel) {
badges.push({
label: baseModel,
type: 'base'
})
}
// Size badge
badges.push({ label: formattedSize, type: 'size' })
// Create display stats from API data
const stats = {
formattedDate: new Date(asset.created_at).toLocaleDateString(),
downloadCount: undefined, // Not available in API
stars: undefined // Not available in API
}
return {
...asset,
description,
formattedSize,
badges,
stats
}
}
// Extract available categories from assets
const availableCategories = computed(() => {
const categorySet = new Set<string>()
assets.forEach((asset) => {
// Second tag is the category (after 'models' root tag)
if (asset.tags.length > 1 && asset.tags[0] === 'models') {
categorySet.add(asset.tags[1])
}
})
return [
{
id: 'all',
label: t('assetBrowser.allModels'),
icon: 'icon-[lucide--folder]'
},
...Array.from(categorySet)
.sort()
.map((category) => ({
id: category,
label: category.charAt(0).toUpperCase() + category.slice(1),
icon: 'icon-[lucide--package]'
}))
]
})
// Compute content title from selected category
const contentTitle = computed(() => {
if (selectedCategory.value === 'all') {
return t('assetBrowser.allModels')
}
const category = availableCategories.value.find(
(cat) => cat.id === selectedCategory.value
)
return category?.label || t('assetBrowser.assets')
})
// Filter functions
const filterByCategory = (category: string) => (asset: AssetItem) => {
if (category === 'all') return true
return asset.tags.includes(category)
}
const filterByQuery = (query: string) => (asset: AssetItem) => {
if (!query) return true
const lowerQuery = query.toLowerCase()
const description = getAssetDescription(asset)
return (
asset.name.toLowerCase().includes(lowerQuery) ||
(description && description.toLowerCase().includes(lowerQuery)) ||
asset.tags.some((tag) => tag.toLowerCase().includes(lowerQuery))
)
}
// Computed filtered and transformed assets
const filteredAssets = computed(() => {
const filtered = assets
.filter(filterByCategory(selectedCategory.value))
.filter(filterByQuery(searchQuery.value))
// Sort assets
filtered.sort((a, b) => {
switch (sortBy.value) {
case 'date':
return (
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
)
case 'name':
default:
return a.name.localeCompare(b.name)
}
})
// Transform to display format
return filtered.map(transformAssetForDisplay)
})
// Actions
function selectAsset(asset: AssetDisplayItem): UUID {
if (import.meta.env.DEV) {
console.log('Asset selected:', asset.id, asset.name)
}
return asset.id
}
return {
// State
searchQuery,
selectedCategory,
sortBy,
// Computed
availableCategories,
contentTitle,
filteredAssets,
// Actions
selectAsset,
transformAssetForDisplay
}
}

View File

@@ -0,0 +1,203 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import { mockAssets } from '@/platform/assets/fixtures/ui-mock-assets'
// Component that simulates the useAssetBrowserDialog functionality with working close
const DialogDemoComponent = {
components: { AssetBrowserModal },
setup() {
const isDialogOpen = ref(false)
const currentNodeType = ref('CheckpointLoaderSimple')
const currentInputName = ref('ckpt_name')
const currentValue = ref('')
const handleOpenDialog = (
nodeType: string,
inputName: string,
value = ''
) => {
currentNodeType.value = nodeType
currentInputName.value = inputName
currentValue.value = value
isDialogOpen.value = true
}
const handleCloseDialog = () => {
isDialogOpen.value = false
}
const handleAssetSelected = (assetPath: string) => {
console.log('Asset selected:', assetPath)
alert(`Selected asset: ${assetPath}`)
isDialogOpen.value = false // Auto-close like the real composable
}
const handleOpenWithCurrentValue = () => {
handleOpenDialog(
'CheckpointLoaderSimple',
'ckpt_name',
'realistic_vision_v5.safetensors'
)
}
return {
isDialogOpen,
currentNodeType,
currentInputName,
currentValue,
handleOpenDialog,
handleOpenWithCurrentValue,
handleCloseDialog,
handleAssetSelected,
mockAssets
}
},
template: `
<div class="relative">
<div class="p-8 space-y-4">
<h2 class="text-2xl font-bold mb-6">Asset Browser Dialog Demo</h2>
<div class="space-y-4">
<div>
<h3 class="text-lg font-semibold mb-2">Different Node Types</h3>
<div class="flex gap-3 flex-wrap">
<button
@click="handleOpenDialog('CheckpointLoaderSimple', 'ckpt_name')"
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Browse Checkpoints
</button>
<button
@click="handleOpenDialog('VAELoader', 'vae_name')"
class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
>
Browse VAE
</button>
<button
@click="handleOpenDialog('ControlNetLoader', 'control_net_name')"
class="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
>
Browse ControlNet
</button>
</div>
</div>
<div>
<h3 class="text-lg font-semibold mb-2">With Current Value</h3>
<button
@click="handleOpenWithCurrentValue"
class="px-4 py-2 bg-orange-600 text-white rounded hover:bg-orange-700"
>
Change Current Model
</button>
<p class="text-sm text-gray-600 mt-1">
Opens with "realistic_vision_v5.safetensors" as current value
</p>
</div>
<div class="mt-8 p-4 bg-gray-100 rounded">
<h4 class="font-semibold mb-2">Instructions:</h4>
<ul class="text-sm space-y-1">
<li>• Click any button to open the Asset Browser dialog</li>
<li>• Select an asset to see the callback in action</li>
<li>• Check the browser console for logged events</li>
<li>• Try toggling the left panel with different asset types</li>
<li>• Close button will work properly in this demo</li>
</ul>
</div>
</div>
</div>
<!-- Dialog Modal Overlay -->
<div
v-if="isDialogOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
@click.self="handleCloseDialog"
>
<div class="w-[80vw] h-[80vh] max-w-[80vw] max-h-[80vh] rounded-2xl overflow-hidden">
<AssetBrowserModal
:assets="mockAssets"
:node-type="currentNodeType"
:input-name="currentInputName"
:current-value="currentValue"
@asset-select="handleAssetSelected"
@close="handleCloseDialog"
/>
</div>
</div>
</div>
`
}
const meta: Meta = {
title: 'Platform/Assets/useAssetBrowserDialog',
parameters: {
layout: 'fullscreen',
docs: {
description: {
component:
'Demonstrates the AssetBrowserModal functionality as used by the useAssetBrowserDialog composable.'
}
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Demo: Story = {
render: () => ({
components: { DialogDemoComponent },
template: `
<div>
<DialogDemoComponent />
<!-- Code Example Section -->
<div class="p-8 border-t border-gray-200 bg-gray-50">
<h2 class="text-2xl font-bold mb-4">Code Example</h2>
<p class="text-gray-600 mb-4">
This is how you would use the composable in your component:
</p>
<div class="bg-white p-4 rounded-lg border shadow-sm">
<pre><code class="text-sm">import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
export default {
setup() {
const assetBrowserDialog = useAssetBrowserDialog()
const openBrowser = () => {
assetBrowserDialog.show({
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
currentValue: '',
onAssetSelected: (assetPath) => {
console.log('Selected:', assetPath)
// Update your component state
}
})
}
return { openBrowser }
}
}</code></pre>
</div>
<div class="mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
<p class="text-sm text-blue-800">
<strong>💡 Try it:</strong> Use the interactive buttons above to see this code in action!
</p>
</div>
</div>
</div>
`
}),
parameters: {
docs: {
description: {
story:
'Complete demo showing both interactive functionality and code examples for using useAssetBrowserDialog to open the Asset Browser modal programmatically.'
}
}
}
}

View File

@@ -0,0 +1,66 @@
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import { useDialogStore } from '@/stores/dialogStore'
interface AssetBrowserDialogProps {
/** ComfyUI node type for context (e.g., 'CheckpointLoaderSimple') */
nodeType: string
/** Widget input name (e.g., 'ckpt_name') */
inputName: string
/** Current selected asset value */
currentValue?: string
/** Callback for when an asset is selected */
onAssetSelected?: (assetPath: string) => void
}
export const useAssetBrowserDialog = () => {
const dialogStore = useDialogStore()
const dialogKey = 'global-asset-browser'
function hide() {
dialogStore.closeDialog({ key: dialogKey })
}
function show(props: AssetBrowserDialogProps) {
const handleAssetSelected = (assetPath: string) => {
props.onAssetSelected?.(assetPath)
hide() // Auto-close on selection
}
const handleClose = () => {
hide()
}
// Default dialog configuration for AssetBrowserModal
const dialogComponentProps = {
headless: true,
modal: true,
closable: false,
pt: {
root: {
class: 'rounded-2xl overflow-hidden'
},
header: {
class: 'p-0 hidden'
},
content: {
class: 'p-0 m-0 h-full w-full'
}
}
}
dialogStore.showDialog({
key: dialogKey,
component: AssetBrowserModal,
props: {
nodeType: props.nodeType,
inputName: props.inputName,
currentValue: props.currentValue,
onSelect: handleAssetSelected,
onClose: handleClose
},
dialogComponentProps
})
}
return { show, hide }
}

View File

@@ -0,0 +1,56 @@
import { uniqWith } from 'es-toolkit'
import { computed } from 'vue'
import type { SelectOption } from '@/components/input/types'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Composable that extracts available filter options from asset data
* Provides reactive computed properties for file formats and base models
*/
export function useAssetFilterOptions(assets: AssetItem[] = []) {
/**
* Extract unique file formats from asset names
* Returns sorted SelectOption array with extensions
*/
const availableFileFormats = computed<SelectOption[]>(() => {
const extensions = assets
.map((asset) => {
const extension = asset.name.split('.').pop()
return extension && extension !== asset.name ? extension : null
})
.filter((extension): extension is string => extension !== null)
const uniqueExtensions = uniqWith(extensions, (a, b) => a === b)
return uniqueExtensions.sort().map((format) => ({
name: `.${format}`,
value: format
}))
})
/**
* Extract unique base models from asset user metadata
* Returns sorted SelectOption array with base model names
*/
const availableBaseModels = computed<SelectOption[]>(() => {
const models = assets
.map((asset) => asset.user_metadata?.base_model)
.filter(
(baseModel): baseModel is string =>
baseModel !== undefined && typeof baseModel === 'string'
)
const uniqueModels = uniqWith(models, (a, b) => a === b)
return uniqueModels.sort().map((model) => ({
name: model,
value: model
}))
})
return {
availableFileFormats,
availableBaseModels
}
}

View File

@@ -0,0 +1,128 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// 🎭 OBVIOUSLY FAKE MOCK DATA - DO NOT USE IN PRODUCTION! 🎭
const fakeFunnyModelNames = [
'🎯_totally_real_model_v420.69',
'🚀_definitely_not_fake_v999',
'🎪_super_legit_checkpoint_pro_max',
'🦄_unicorn_dreams_totally_real.model',
'🍕_pizza_generator_supreme',
'🎸_rock_star_fake_data_v1337',
'🌮_taco_tuesday_model_deluxe',
'🦖_dino_nugget_generator_v3',
'🎮_gamer_fuel_checkpoint_xl',
'🍄_mushroom_kingdom_diffusion',
'🏴_pirate_treasure_model_arr',
'🦋_butterfly_effect_generator',
'🎺_jazz_hands_checkpoint_pro',
'🥨_pretzel_logic_model_v2',
'🌙_midnight_snack_generator',
'🎭_drama_llama_checkpoint',
'🧙_wizard_hat_diffusion_xl',
'🎪_circus_peanut_model_v4',
'🦒_giraffe_neck_generator',
'🎲_random_stuff_checkpoint_max'
]
const obviouslyFakeDescriptions = [
'⚠️ FAKE DATA: Generates 100% authentic fake images with premium mock quality',
'🎭 MOCK ALERT: This totally real model creates absolutely genuine fake content',
'🚨 NOT REAL: Professional-grade fake imagery for your mock data needs',
'🎪 DEMO ONLY: Circus-quality fake generation with extra mock seasoning',
'🍕 FAKE FOOD: Generates delicious fake pizzas (not edible in reality)',
"🎸 MOCK ROCK: Creates fake rock stars who definitely don't exist",
'🌮 TACO FAKERY: Tuesday-themed fake tacos for your mock appetite',
'🦖 PREHISTORIC FAKE: Generates extinct fake dinosaurs for demo purposes',
'🎮 FAKE GAMING: Level up your mock data with obviously fake content',
'🍄 FUNGI FICTION: Magical fake mushrooms from the demo dimension',
'🏴‍☠️ FAKE TREASURE: Arr! This be mock data for ye demo needs, matey!',
'🦋 DEMO EFFECT: Small fake changes create big mock differences',
'🎺 JAZZ FAKERY: Smooth fake jazz for your mock listening pleasure',
'🥨 MOCK LOGIC: Twisted fake reasoning for your demo requirements',
'🌙 MIDNIGHT MOCK: Late-night fake snacks for your demo hunger',
'🎭 FAKE DRAMA: Over-the-top mock emotions for demo entertainment',
'🧙‍♀️ WIZARD MOCK: Magically fake spells cast with demo ingredients',
'🎪 CIRCUS FAKE: Big top mock entertainment under the demo tent',
'🦒 TALL FAKE: Reaches new heights of obviously fake content',
'🎲 RANDOM MOCK: Generates random fake stuff for your demo pleasure'
]
// API-compliant tag structure: first tag must be root (models/input/output), second is category
const modelCategories = ['checkpoints', 'loras', 'embeddings', 'vae']
const baseModels = ['sd15', 'sdxl', 'sd35']
const fileExtensions = ['.safetensors', '.ckpt', '.pt']
const mimeTypes = [
'application/octet-stream',
'application/x-pytorch',
'application/x-safetensors'
]
function getRandomElement<T>(array: T[]): T {
return array[Math.floor(Math.random() * array.length)]
}
function getRandomNumber(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
function getRandomISODate(): string {
const start = new Date('2024-01-01').getTime()
const end = new Date('2024-12-31').getTime()
const randomTime = start + Math.random() * (end - start)
return new Date(randomTime).toISOString()
}
function generateFakeAssetHash(): string {
const chars = '0123456789abcdef'
let hash = 'blake3:'
for (let i = 0; i < 64; i++) {
hash += chars[Math.floor(Math.random() * chars.length)]
}
return hash
}
// 🎭 CREATES OBVIOUSLY FAKE ASSETS FOR DEMO/TEST PURPOSES ONLY! 🎭
export function createMockAssets(count: number = 20): AssetItem[] {
return Array.from({ length: count }, (_, index) => {
const category = getRandomElement(modelCategories)
const baseModel = getRandomElement(baseModels)
const extension = getRandomElement(fileExtensions)
const mimeType = getRandomElement(mimeTypes)
const sizeInBytes = getRandomNumber(
500 * 1024 * 1024,
8 * 1024 * 1024 * 1024
) // 500MB to 8GB
const createdAt = getRandomISODate()
const updatedAt = createdAt
const lastAccessTime = getRandomISODate()
const fakeFileName = `${fakeFunnyModelNames[index]}${extension}`
return {
id: `mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake`,
name: fakeFileName,
asset_hash: generateFakeAssetHash(),
size: sizeInBytes,
mime_type: mimeType,
tags: [
'models', // Root tag (required first)
category, // Category tag (required second for models)
'fake-data', // Obviously fake tag
...(Math.random() > 0.5 ? ['demo-mode'] : ['test-only']),
...(Math.random() > 0.7 ? ['obviously-mock'] : [])
],
preview_url: `/api/assets/mock-asset-uuid-${(index + 1).toString().padStart(3, '0')}-fake/content`,
created_at: createdAt,
updated_at: updatedAt,
last_access_time: lastAccessTime,
user_metadata: {
description: obviouslyFakeDescriptions[index],
base_model: baseModel,
original_name: fakeFunnyModelNames[index],
warning: '🚨 THIS IS FAKE DEMO DATA - NOT A REAL MODEL! 🚨'
}
}
})
}
export const mockAssets = createMockAssets(20)

View File

@@ -1,12 +1,19 @@
import { z } from 'zod'
// Zod schemas for asset API validation
// Zod schemas for asset API validation matching ComfyUI Assets REST API spec
const zAsset = z.object({
id: z.string(),
name: z.string(),
tags: z.array(z.string()),
asset_hash: z.string(),
size: z.number(),
created_at: z.string().optional()
mime_type: z.string(),
tags: z.array(z.string()),
preview_url: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
last_access_time: z.string(),
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
preview_id: z.string().nullable().optional()
})
const zAssetResponse = z.object({
@@ -20,19 +27,22 @@ const zModelFolder = z.object({
folders: z.array(z.string())
})
// Zod schema for ModelFile to align with interface
const zModelFile = z.object({
name: z.string(),
pathIndex: z.number()
})
// Export schemas following repository patterns
export const assetResponseSchema = zAssetResponse
// Export types derived from Zod schemas
export type AssetItem = z.infer<typeof zAsset>
export type AssetResponse = z.infer<typeof zAssetResponse>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>
// Common interfaces for API responses
export interface ModelFile {
name: string
pathIndex: number
}
// Legacy interface for backward compatibility (now aligned with Zod schema)
export interface ModelFolderInfo {
name: string
folders: string[]

View File

@@ -67,7 +67,7 @@ function createAssetService() {
)
// Blacklist directories we don't want to show
const blacklistedDirectories = ['configs']
const blacklistedDirectories = new Set(['configs'])
// Extract directory names from assets that actually exist, exclude missing assets
const discoveredFolders = new Set<string>(
@@ -75,7 +75,7 @@ function createAssetService() {
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
?.flatMap((asset) => asset.tags)
?.filter(
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag)
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag)
) ?? []
)

View File

@@ -0,0 +1,27 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
/**
* Type-safe utilities for extracting metadata from assets
*/
/**
* Safely extracts string description from asset metadata
* @param asset - The asset to extract description from
* @returns The description string or null if not present/not a string
*/
export function getAssetDescription(asset: AssetItem): string | null {
return typeof asset.user_metadata?.description === 'string'
? asset.user_metadata.description
: null
}
/**
* Safely extracts string base_model from asset metadata
* @param asset - The asset to extract base_model from
* @returns The base_model string or null if not present/not a string
*/
export function getAssetBaseModel(asset: AssetItem): string | null {
return typeof asset.user_metadata?.base_model === 'string'
? asset.user_metadata.base_model
: null
}

View File

@@ -6,6 +6,16 @@ import { iconCollection } from './build/customIconCollection'
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
safelist: [
'icon-[lucide--folder]',
'icon-[lucide--package]',
'icon-[lucide--image]',
'icon-[lucide--video]',
'icon-[lucide--box]',
'icon-[lucide--audio-waveform]',
'icon-[lucide--message-circle]'
],
plugins: [
addDynamicIconSelectors({
iconSets: {

View File

@@ -0,0 +1,304 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from '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'
// Mock external dependencies with minimal functionality needed for business logic tests
vi.mock('@/components/input/SearchBox.vue', () => ({
default: {
name: 'SearchBox',
props: ['modelValue', 'size', 'placeholder', 'class'],
emits: ['update:modelValue'],
template: `
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
data-testid="search-box"
/>
`
}
}))
vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({
default: {
name: 'BaseModalLayout',
props: ['contentTitle'],
emits: ['close'],
template: `
<div data-testid="base-modal-layout">
<div v-if="$slots.leftPanel" data-testid="left-panel">
<slot name="leftPanel" />
</div>
<div data-testid="header">
<slot name="header" />
</div>
<div data-testid="content">
<slot name="content" />
</div>
</div>
`
}
}))
vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({
default: {
name: 'LeftSidePanel',
props: ['modelValue', 'navItems'],
emits: ['update:modelValue'],
template: `
<div data-testid="left-side-panel">
<button
v-for="item in navItems"
:key="item.id"
@click="$emit('update:modelValue', item.id)"
:data-testid="'nav-item-' + item.id"
:class="{ active: modelValue === item.id }"
>
{{ item.label }}
</button>
</div>
`
}
}))
vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({
default: {
name: 'AssetGrid',
props: ['assets'],
emits: ['asset-select'],
template: `
<div data-testid="asset-grid">
<div
v-for="asset in assets"
:key="asset.id"
@click="$emit('asset-select', asset)"
:data-testid="'asset-' + asset.id"
class="asset-card"
>
{{ asset.name }}
</div>
<div v-if="assets.length === 0" data-testid="empty-state">
No assets found
</div>
</div>
`
}
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
describe('AssetBrowserModal', () => {
const createTestAsset = (
id: string,
name: string,
category: string
): AssetItem => ({
id,
name,
asset_hash: `blake3:${id.padEnd(64, '0')}`,
size: 1024000,
mime_type: 'application/octet-stream',
tags: ['models', category, 'test'],
preview_url: `/api/assets/${id}/content`,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
user_metadata: {
description: `Test ${name}`,
base_model: 'sd15'
}
})
const createWrapper = (
assets: AssetItem[] = [],
props: Record<string, unknown> = {}
) => {
const pinia = createPinia()
setActivePinia(pinia)
return mount(AssetBrowserModal, {
props: {
assets: assets,
...props
},
global: {
plugins: [pinia],
stubs: {
'i-lucide:folder': {
template: '<div data-testid="folder-icon"></div>'
}
},
mocks: {
$t: (key: string) => key
}
}
})
}
describe('Search Functionality', () => {
it('filters assets when search query changes', async () => {
const assets = [
createTestAsset('asset1', 'Checkpoint Model A', 'checkpoints'),
createTestAsset('asset2', 'Checkpoint Model B', 'checkpoints'),
createTestAsset('asset3', 'LoRA Model C', 'loras')
]
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 filteredAssets = assetGrid.props('assets') as AssetDisplayItem[]
expect(filteredAssets.length).toBe(1)
expect(filteredAssets[0].name).toContain('LoRA')
})
it('shows empty state when search has no results', async () => {
const assets = [
createTestAsset('asset1', 'Checkpoint Model', 'checkpoints')
]
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 })
// Wait for Vue reactivity and component mounting
await nextTick()
// Check if left panel exists first (since we have multiple categories)
const leftPanel = wrapper.find('[data-testid="left-panel"]')
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')
})
})
describe('Asset Selection', () => {
it('emits asset-select event when asset is selected', async () => {
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
const wrapper = createWrapper(assets)
// Click on first asset
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
const emitted = wrapper.emitted('asset-select')
expect(emitted).toBeDefined()
expect(emitted).toHaveLength(1)
const emittedAsset = emitted![0][0] as AssetDisplayItem
expect(emittedAsset.id).toBe('asset1')
})
it('executes onSelect callback when provided', async () => {
const onSelectSpy = vi.fn()
const assets = [createTestAsset('asset1', 'Test Model', 'checkpoints')]
const wrapper = createWrapper(assets, { onSelect: onSelectSpy })
// Click on first asset
await wrapper.find('[data-testid="asset-asset1"]').trigger('click')
expect(onSelectSpy).toHaveBeenCalledWith('Test Model')
})
})
describe('Left Panel Conditional Logic', () => {
it('hides left panel by default when showLeftPanel prop is undefined', () => {
const singleCategoryAssets = [
createTestAsset('single1', 'Asset 1', 'checkpoints'),
createTestAsset('single2', 'Asset 2', 'checkpoints')
]
const wrapper = createWrapper(singleCategoryAssets)
expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(false)
})
it('shows left panel when showLeftPanel prop is explicitly true', () => {
const singleCategoryAssets = [
createTestAsset('single1', 'Asset 1', 'checkpoints')
]
// Force show even with single category
const wrapper = createWrapper(singleCategoryAssets, {
showLeftPanel: true
})
expect(wrapper.find('[data-testid="left-panel"]').exists()).toBe(true)
// Force hide even with multiple categories
wrapper.unmount()
const multiCategoryAssets = [
createTestAsset('asset1', 'Checkpoint', 'checkpoints'),
createTestAsset('asset2', 'LoRA', 'loras')
]
const wrapper2 = createWrapper(multiCategoryAssets, {
showLeftPanel: false
})
expect(wrapper2.find('[data-testid="left-panel"]').exists()).toBe(false)
})
})
})

View File

@@ -0,0 +1,138 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import type { FilterState } from '@/platform/assets/components/AssetFilterBar.vue'
// Mock components with minimal functionality for business logic testing
vi.mock('@/components/input/MultiSelect.vue', () => ({
default: {
name: 'MultiSelect',
props: {
modelValue: Array,
label: String,
options: Array,
class: String
},
emits: ['update:modelValue'],
template: `
<div data-testid="multi-select">
<select multiple @change="$emit('update:modelValue', Array.from($event.target.selectedOptions).map(o => ({ name: o.text, value: o.value })))">
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.name }}
</option>
</select>
</div>
`
}
}))
vi.mock('@/components/input/SingleSelect.vue', () => ({
default: {
name: 'SingleSelect',
props: {
modelValue: String,
label: String,
options: Array,
class: String
},
emits: ['update:modelValue'],
template: `
<div data-testid="single-select">
<select @change="$emit('update:modelValue', $event.target.value)">
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.name }}
</option>
</select>
</div>
`
}
}))
// Test factory functions
describe('AssetFilterBar', () => {
describe('Filter State Management', () => {
it('maintains correct initial state', () => {
const wrapper = mount(AssetFilterBar)
// Test initial state through component props
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
const singleSelect = wrapper.findComponent({ name: 'SingleSelect' })
expect(multiSelects[0].props('modelValue')).toEqual([])
expect(multiSelects[1].props('modelValue')).toEqual([])
expect(singleSelect.props('modelValue')).toBe('name-asc')
})
it('handles multiple simultaneous filter changes correctly', async () => {
const wrapper = mount(AssetFilterBar)
// Update file formats
const fileFormatSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[0]
await fileFormatSelect.vm.$emit('update:modelValue', [
{ name: '.ckpt', value: 'ckpt' },
{ name: '.safetensors', value: 'safetensors' }
])
await nextTick()
// Update base models
const baseModelSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[1]
await baseModelSelect.vm.$emit('update:modelValue', [
{ name: 'SD XL', value: 'sdxl' }
])
await nextTick()
// Update sort
const sortSelect = wrapper.findComponent({ name: 'SingleSelect' })
await sortSelect.vm.$emit('update:modelValue', 'popular')
await nextTick()
const emitted = wrapper.emitted('filterChange')
expect(emitted).toHaveLength(3)
// Check final state
const finalState: FilterState = emitted![2][0] as FilterState
expect(finalState.fileFormats).toEqual(['ckpt', 'safetensors'])
expect(finalState.baseModels).toEqual(['sdxl'])
expect(finalState.sortBy).toBe('popular')
})
it('ensures FilterState interface compliance', async () => {
const wrapper = mount(AssetFilterBar)
const fileFormatSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[0]
await fileFormatSelect.vm.$emit('update:modelValue', [
{ name: '.ckpt', value: 'ckpt' }
])
await nextTick()
const emitted = wrapper.emitted('filterChange')
const filterState = emitted![0][0] as FilterState
// Type and structure assertions
expect(Array.isArray(filterState.fileFormats)).toBe(true)
expect(Array.isArray(filterState.baseModels)).toBe(true)
expect(typeof filterState.sortBy).toBe('string')
// Value type assertions
expect(filterState.fileFormats.every((f) => typeof f === 'string')).toBe(
true
)
expect(filterState.baseModels.every((m) => typeof m === 'string')).toBe(
true
)
})
})
})

View File

@@ -0,0 +1,323 @@
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
describe('useAssetBrowser', () => {
// Test fixtures - minimal data focused on functionality being tested
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
id: 'test-id',
name: 'test-asset.safetensors',
asset_hash: 'blake3:abc123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
...overrides
})
describe('Asset Transformation', () => {
it('transforms API asset to include display properties', () => {
const apiAsset = createApiAsset({
size: 2147483648, // 2GB
user_metadata: { description: 'Test model' }
})
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
const result = transformAssetForDisplay(apiAsset)
// Preserves API properties
expect(result.id).toBe(apiAsset.id)
expect(result.name).toBe(apiAsset.name)
// Adds display properties
expect(result.description).toBe('Test model')
expect(result.formattedSize).toBe('2 GB')
expect(result.badges).toContainEqual({
label: 'checkpoints',
type: 'type'
})
expect(result.badges).toContainEqual({ label: '2 GB', type: 'size' })
})
it('creates fallback description from tags when metadata missing', () => {
const apiAsset = createApiAsset({
tags: ['models', 'loras'],
user_metadata: undefined
})
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
const result = transformAssetForDisplay(apiAsset)
expect(result.description).toBe('loras model')
})
it('formats various file sizes correctly', () => {
const { transformAssetForDisplay } = useAssetBrowser([])
const testCases = [
{ size: 512, expected: '512 B' },
{ size: 1536, expected: '1.5 KB' },
{ size: 2097152, expected: '2 MB' },
{ size: 3221225472, expected: '3 GB' }
]
testCases.forEach(({ size, expected }) => {
const asset = createApiAsset({ size })
const result = transformAssetForDisplay(asset)
expect(result.formattedSize).toBe(expected)
})
})
})
describe('Tag-Based Filtering', () => {
it('filters assets by category tag', async () => {
const assets = [
createApiAsset({ id: '1', tags: ['models', 'checkpoints'] }),
createApiAsset({ id: '2', tags: ['models', 'loras'] }),
createApiAsset({ id: '3', tags: ['models', 'checkpoints'] })
]
const { selectedCategory, filteredAssets } = useAssetBrowser(assets)
selectedCategory.value = 'checkpoints'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
expect(
filteredAssets.value.every((asset) =>
asset.tags.includes('checkpoints')
)
).toBe(true)
})
it('returns all assets when category is "all"', async () => {
const assets = [
createApiAsset({ id: '1', tags: ['models', 'checkpoints'] }),
createApiAsset({ id: '2', tags: ['models', 'loras'] })
]
const { selectedCategory, filteredAssets } = useAssetBrowser(assets)
selectedCategory.value = 'all'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
})
})
describe('Search Functionality', () => {
it('searches across asset name', async () => {
const assets = [
createApiAsset({ name: 'realistic_vision.safetensors' }),
createApiAsset({ name: 'anime_style.ckpt' }),
createApiAsset({ name: 'photorealistic_v2.safetensors' })
]
const { searchQuery, filteredAssets } = useAssetBrowser(assets)
searchQuery.value = 'realistic'
await nextTick()
expect(filteredAssets.value).toHaveLength(2)
expect(
filteredAssets.value.every((asset) =>
asset.name.toLowerCase().includes('realistic')
)
).toBe(true)
})
it('searches in user metadata description', async () => {
const assets = [
createApiAsset({
name: 'model1.safetensors',
user_metadata: { description: 'fantasy artwork model' }
}),
createApiAsset({
name: 'model2.safetensors',
user_metadata: { description: 'portrait photography' }
})
]
const { searchQuery, filteredAssets } = useAssetBrowser(assets)
searchQuery.value = 'fantasy'
await nextTick()
expect(filteredAssets.value).toHaveLength(1)
expect(filteredAssets.value[0].name).toBe('model1.safetensors')
})
it('handles empty search results', async () => {
const assets = [createApiAsset({ name: 'test.safetensors' })]
const { searchQuery, filteredAssets } = useAssetBrowser(assets)
searchQuery.value = 'nonexistent'
await nextTick()
expect(filteredAssets.value).toHaveLength(0)
})
})
describe('Combined Search and Filtering', () => {
it('applies both search and category filter', async () => {
const assets = [
createApiAsset({
name: 'realistic_checkpoint.safetensors',
tags: ['models', 'checkpoints']
}),
createApiAsset({
name: 'realistic_lora.safetensors',
tags: ['models', 'loras']
}),
createApiAsset({
name: 'anime_checkpoint.safetensors',
tags: ['models', 'checkpoints']
})
]
const { searchQuery, selectedCategory, filteredAssets } =
useAssetBrowser(assets)
searchQuery.value = 'realistic'
selectedCategory.value = 'checkpoints'
await nextTick()
expect(filteredAssets.value).toHaveLength(1)
expect(filteredAssets.value[0].name).toBe(
'realistic_checkpoint.safetensors'
)
})
})
describe('Sorting', () => {
it('sorts assets by name', async () => {
const assets = [
createApiAsset({ name: 'zebra.safetensors' }),
createApiAsset({ name: 'alpha.safetensors' }),
createApiAsset({ name: 'beta.safetensors' })
]
const { sortBy, filteredAssets } = useAssetBrowser(assets)
sortBy.value = 'name'
await nextTick()
const names = filteredAssets.value.map((asset) => asset.name)
expect(names).toEqual([
'alpha.safetensors',
'beta.safetensors',
'zebra.safetensors'
])
})
it('sorts assets by creation date', async () => {
const assets = [
createApiAsset({ created_at: '2024-03-01T00:00:00Z' }),
createApiAsset({ created_at: '2024-01-01T00:00:00Z' }),
createApiAsset({ created_at: '2024-02-01T00:00:00Z' })
]
const { sortBy, filteredAssets } = useAssetBrowser(assets)
sortBy.value = 'date'
await nextTick()
const dates = filteredAssets.value.map((asset) => asset.created_at)
expect(dates).toEqual([
'2024-03-01T00:00:00Z',
'2024-02-01T00:00:00Z',
'2024-01-01T00:00:00Z'
])
})
})
describe('Asset Selection', () => {
it('returns selected asset UUID for efficient handling', () => {
const asset = createApiAsset({
id: 'test-uuid-123',
name: 'selected_model.safetensors'
})
const { selectAsset, transformAssetForDisplay } = useAssetBrowser([asset])
const displayAsset = transformAssetForDisplay(asset)
const result = selectAsset(displayAsset)
expect(result).toBe('test-uuid-123')
})
})
describe('Dynamic Category Extraction', () => {
it('extracts categories from asset tags', () => {
const assets = [
createApiAsset({ tags: ['models', 'checkpoints'] }),
createApiAsset({ tags: ['models', 'loras'] }),
createApiAsset({ tags: ['models', 'checkpoints'] }) // duplicate
]
const { availableCategories } = useAssetBrowser(assets)
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{
id: 'checkpoints',
label: 'Checkpoints',
icon: 'icon-[lucide--package]'
},
{ id: 'loras', label: 'Loras', icon: 'icon-[lucide--package]' }
])
})
it('handles assets with no category tag', () => {
const assets = [
createApiAsset({ tags: ['models'] }), // No second tag
createApiAsset({ tags: ['models', 'vae'] })
]
const { availableCategories } = useAssetBrowser(assets)
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{ id: 'vae', label: 'Vae', icon: 'icon-[lucide--package]' }
])
})
it('ignores non-models root tags', () => {
const assets = [
createApiAsset({ tags: ['input', 'images'] }),
createApiAsset({ tags: ['models', 'checkpoints'] })
]
const { availableCategories } = useAssetBrowser(assets)
expect(availableCategories.value).toEqual([
{ id: 'all', label: 'All Models', icon: 'icon-[lucide--folder]' },
{
id: 'checkpoints',
label: 'Checkpoints',
icon: 'icon-[lucide--package]'
}
])
})
it('computes content title from selected category', () => {
const assets = [createApiAsset({ tags: ['models', 'checkpoints'] })]
const { selectedCategory, contentTitle } = useAssetBrowser(assets)
// Default
expect(contentTitle.value).toBe('All Models')
// Set specific category
selectedCategory.value = 'checkpoints'
expect(contentTitle.value).toBe('Checkpoints')
// Unknown category
selectedCategory.value = 'unknown'
expect(contentTitle.value).toBe('Assets')
})
})
})

View File

@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { useDialogStore } from '@/stores/dialogStore'
// Mock the dialog store
vi.mock('@/stores/dialogStore')
// Test factory functions
interface AssetBrowserProps {
nodeType: string
inputName: string
onAssetSelected?: ReturnType<typeof vi.fn>
}
function createAssetBrowserProps(
overrides: Partial<AssetBrowserProps> = {}
): AssetBrowserProps {
return {
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
...overrides
}
}
describe('useAssetBrowserDialog', () => {
describe('Asset Selection Flow', () => {
it('auto-closes dialog when asset is selected', () => {
// Create fresh mocks for this test
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 onAssetSelected = vi.fn()
const props = createAssetBrowserProps({ onAssetSelected })
assetBrowserDialog.show(props)
// Get the onSelect handler that was passed to the dialog
const dialogCall = mockShowDialog.mock.calls[0][0]
const onSelectHandler = dialogCall.props.onSelect
// Simulate asset selection
onSelectHandler('selected-asset-path')
// Should call the original callback and close dialog
expect(onAssetSelected).toHaveBeenCalledWith('selected-asset-path')
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'global-asset-browser'
})
})
it('closes dialog when close handler is called', () => {
// Create fresh mocks for this test
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 props = createAssetBrowserProps()
assetBrowserDialog.show(props)
// Get the onClose handler that was passed to the dialog
const dialogCall = mockShowDialog.mock.calls[0][0]
const onCloseHandler = dialogCall.props.onClose
// Simulate dialog close
onCloseHandler()
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'global-asset-browser'
})
})
})
})

View File

@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// Test factory functions
function createTestAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'test-uuid',
name: 'test-model.safetensors',
asset_hash: 'blake3:test123',
size: 123456,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
user_metadata: {
base_model: 'sd15'
},
...overrides
}
}
describe('useAssetFilterOptions', () => {
describe('File Format Extraction', () => {
it('extracts file formats from asset names', () => {
const assets = [
createTestAsset({ name: 'model1.safetensors' }),
createTestAsset({ name: 'model2.ckpt' }),
createTestAsset({ name: 'model3.pt' })
]
const { availableFileFormats } = useAssetFilterOptions(assets)
expect(availableFileFormats.value).toEqual([
{ name: '.ckpt', value: 'ckpt' },
{ name: '.pt', value: 'pt' },
{ name: '.safetensors', value: 'safetensors' }
])
})
it('handles duplicate file formats', () => {
const assets = [
createTestAsset({ name: 'model1.safetensors' }),
createTestAsset({ name: 'model2.safetensors' }),
createTestAsset({ name: 'model3.ckpt' })
]
const { availableFileFormats } = useAssetFilterOptions(assets)
expect(availableFileFormats.value).toEqual([
{ name: '.ckpt', value: 'ckpt' },
{ name: '.safetensors', value: 'safetensors' }
])
})
it('handles assets with no file extension', () => {
const assets = [
createTestAsset({ name: 'model_no_extension' }),
createTestAsset({ name: 'model.safetensors' })
]
const { availableFileFormats } = useAssetFilterOptions(assets)
expect(availableFileFormats.value).toEqual([
{ name: '.safetensors', value: 'safetensors' }
])
})
it('handles empty asset list', () => {
const { availableFileFormats } = useAssetFilterOptions([])
expect(availableFileFormats.value).toEqual([])
})
})
describe('Base Model Extraction', () => {
it('extracts base models from user metadata', () => {
const assets = [
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
createTestAsset({ user_metadata: { base_model: 'sdxl' } }),
createTestAsset({ user_metadata: { base_model: 'sd35' } })
]
const { availableBaseModels } = useAssetFilterOptions(assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sd15', value: 'sd15' },
{ name: 'sd35', value: 'sd35' },
{ name: 'sdxl', value: 'sdxl' }
])
})
it('handles duplicate base models', () => {
const assets = [
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
createTestAsset({ user_metadata: { base_model: 'sd15' } }),
createTestAsset({ user_metadata: { base_model: 'sdxl' } })
]
const { availableBaseModels } = useAssetFilterOptions(assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sd15', value: 'sd15' },
{ name: 'sdxl', value: 'sdxl' }
])
})
it('handles assets with missing user_metadata', () => {
const assets = [
createTestAsset({ user_metadata: undefined }),
createTestAsset({ user_metadata: { base_model: 'sd15' } })
]
const { availableBaseModels } = useAssetFilterOptions(assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sd15', value: 'sd15' }
])
})
it('handles assets with missing base_model field', () => {
const assets = [
createTestAsset({ user_metadata: { description: 'A test model' } }),
createTestAsset({ user_metadata: { base_model: 'sdxl' } })
]
const { availableBaseModels } = useAssetFilterOptions(assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sdxl', value: 'sdxl' }
])
})
it('handles empty asset list', () => {
const { availableBaseModels } = useAssetFilterOptions([])
expect(availableBaseModels.value).toEqual([])
})
})
describe('Reactivity', () => {
it('returns computed properties that can be reactive', () => {
const assets = [createTestAsset({ name: 'model.safetensors' })]
const { availableFileFormats, availableBaseModels } =
useAssetFilterOptions(assets)
// These should be computed refs
expect(availableFileFormats.value).toBeDefined()
expect(availableBaseModels.value).toBeDefined()
expect(typeof availableFileFormats.value).toBe('object')
expect(typeof availableBaseModels.value).toBe('object')
expect(Array.isArray(availableFileFormats.value)).toBe(true)
expect(Array.isArray(availableBaseModels.value)).toBe(true)
})
})
})

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModel,
getAssetDescription
} from '@/platform/assets/utils/assetMetadataUtils'
describe('assetMetadataUtils', () => {
const mockAsset: AssetItem = {
id: 'test-id',
name: 'test-model',
asset_hash: 'hash123',
size: 1024,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z'
}
describe('getAssetDescription', () => {
it('should return string description when present', () => {
const asset = {
...mockAsset,
user_metadata: { description: 'A test model' }
}
expect(getAssetDescription(asset)).toBe('A test model')
})
it('should return null when description is not a string', () => {
const asset = {
...mockAsset,
user_metadata: { description: 123 }
}
expect(getAssetDescription(asset)).toBeNull()
})
it('should return null when no metadata', () => {
expect(getAssetDescription(mockAsset)).toBeNull()
})
})
describe('getAssetBaseModel', () => {
it('should return string base_model when present', () => {
const asset = {
...mockAsset,
user_metadata: { base_model: 'SDXL' }
}
expect(getAssetBaseModel(asset)).toBe('SDXL')
})
it('should return null when base_model is not a string', () => {
const asset = {
...mockAsset,
user_metadata: { base_model: 123 }
}
expect(getAssetBaseModel(asset)).toBeNull()
})
it('should return null when no metadata', () => {
expect(getAssetBaseModel(mockAsset)).toBeNull()
})
})
})

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { api } from '@/scripts/api'
@@ -17,26 +18,39 @@ vi.mock('@/stores/modelToNodeStore', () => ({
}))
}))
// Helper to create API-compliant test assets
function createTestAsset(overrides: Partial<AssetItem> = {}) {
return {
id: 'test-uuid',
name: 'test-model.safetensors',
asset_hash: 'blake3:test123',
size: 123456,
mime_type: 'application/octet-stream',
tags: ['models', 'checkpoints'],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
last_access_time: '2024-01-01T00:00:00Z',
...overrides
}
}
// Test data constants
const MOCK_ASSETS = {
checkpoints: {
checkpoints: createTestAsset({
id: 'uuid-1',
name: 'model1.safetensors',
tags: ['models', 'checkpoints'],
size: 123456
},
loras: {
tags: ['models', 'checkpoints']
}),
loras: createTestAsset({
id: 'uuid-2',
name: 'model2.safetensors',
tags: ['models', 'loras'],
size: 654321
},
vae: {
tags: ['models', 'loras']
}),
vae: createTestAsset({
id: 'uuid-3',
name: 'vae1.safetensors',
tags: ['models', 'vae'],
size: 789012
}
tags: ['models', 'vae']
})
} as const
// Helper functions
@@ -66,24 +80,21 @@ describe('assetService', () => {
describe('getAssetModelFolders', () => {
it('should extract directory names from asset tags and filter blacklisted ones', async () => {
const assets = [
{
createTestAsset({
id: 'uuid-1',
name: 'checkpoint1.safetensors',
tags: ['models', 'checkpoints'],
size: 123456
},
{
tags: ['models', 'checkpoints']
}),
createTestAsset({
id: 'uuid-2',
name: 'config.yaml',
tags: ['models', 'configs'], // Blacklisted
size: 654321
},
{
tags: ['models', 'configs'] // Blacklisted
}),
createTestAsset({
id: 'uuid-3',
name: 'vae1.safetensors',
tags: ['models', 'vae'],
size: 789012
}
tags: ['models', 'vae']
})
]
mockApiResponse(assets)
@@ -123,12 +134,11 @@ describe('assetService', () => {
const assets = [
{ ...MOCK_ASSETS.checkpoints, name: 'valid.safetensors' },
{ ...MOCK_ASSETS.loras, name: 'lora.safetensors' }, // Wrong tag
{
createTestAsset({
id: 'uuid-4',
name: 'missing-model.safetensors',
tags: ['models', 'checkpoints', 'missing'], // Has missing tag
size: 654321
}
tags: ['models', 'checkpoints', 'missing'] // Has missing tag
})
]
mockApiResponse(assets)