diff --git a/.gitignore b/.gitignore index 5a58d1b1a1..5473190ea9 100644 --- a/.gitignore +++ b/.gitignore @@ -78,8 +78,8 @@ vite.config.mts.timestamp-*.mjs *storybook.log storybook-static - - +# MCP Servers +.playwright-mcp/* .nx/cache .nx/workspace-data diff --git a/.storybook/main.ts b/.storybook/main.ts index a799ec143e..aa6bb1fbd1 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -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 & { 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: { diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 747bbe8029..bfe81f4311 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -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) - const pinia = createPinia() - app.use(pinia) - // Initialize stores - useColorPaletteStore(pinia) - useWidgetStore(pinia) + // Create Pinia instance + const pinia = createPinia() + app.use(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 = { diff --git a/src/locales/en/main.json b/src/locales/en/main.json index e70301ce79..3abd853357 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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" } } diff --git a/src/platform/assets/components/AssetBadgeGroup.vue b/src/platform/assets/components/AssetBadgeGroup.vue new file mode 100644 index 0000000000..bb27153c21 --- /dev/null +++ b/src/platform/assets/components/AssetBadgeGroup.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/platform/assets/components/AssetBrowserModal.stories.ts b/src/platform/assets/components/AssetBrowserModal.stories.ts new file mode 100644 index 0000000000..acc93181de --- /dev/null +++ b/src/platform/assets/components/AssetBrowserModal.stories.ts @@ -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 = { + 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 + +// 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: ` +
+ +
+ ` + }) +} + +// 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: ` +
+ +
+ ` + }), + 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: ` +
+ +
+ ` + }), + parameters: { + docs: { + description: { + story: + 'Modal with left panel explicitly disabled via showLeftPanel=false.' + } + } + } +} diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue new file mode 100644 index 0000000000..de05f437dc --- /dev/null +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -0,0 +1,95 @@ + + + diff --git a/src/platform/assets/components/AssetCard.stories.ts b/src/platform/assets/components/AssetCard.stories.ts new file mode 100644 index 0000000000..2b3532a05e --- /dev/null +++ b/src/platform/assets/components/AssetCard.stories.ts @@ -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 => ({ + ...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 = { + title: 'Platform/Assets/AssetCard', + component: AssetCard, + parameters: { + layout: 'centered' + }, + decorators: [ + () => ({ + template: + '
' + }) + ] +} + +export default meta +type Story = StoryObj + +export const Interactive: Story = { + args: { + asset: createAssetData(), + interactive: true + }, + decorators: [ + () => ({ + template: + '
' + }) + ], + 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: + '
' + }) + ], + 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: ` +
+ +
+ ` + }), + 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.' + } + } + } +} diff --git a/src/platform/assets/components/AssetCard.vue b/src/platform/assets/components/AssetCard.vue new file mode 100644 index 0000000000..e379099c19 --- /dev/null +++ b/src/platform/assets/components/AssetCard.vue @@ -0,0 +1,111 @@ + + + diff --git a/src/platform/assets/components/AssetFilterBar.vue b/src/platform/assets/components/AssetFilterBar.vue new file mode 100644 index 0000000000..1f3295b439 --- /dev/null +++ b/src/platform/assets/components/AssetFilterBar.vue @@ -0,0 +1,102 @@ + + + diff --git a/src/platform/assets/components/AssetGrid.vue b/src/platform/assets/components/AssetGrid.vue new file mode 100644 index 0000000000..35122fd52c --- /dev/null +++ b/src/platform/assets/components/AssetGrid.vue @@ -0,0 +1,70 @@ + + + diff --git a/src/platform/assets/composables/useAssetBrowser.ts b/src/platform/assets/composables/useAssetBrowser.ts new file mode 100644 index 0000000000..22af0cf4ef --- /dev/null +++ b/src/platform/assets/composables/useAssetBrowser.ts @@ -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() + + 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 + } +} diff --git a/src/platform/assets/composables/useAssetBrowserDialog.stories.ts b/src/platform/assets/composables/useAssetBrowserDialog.stories.ts new file mode 100644 index 0000000000..e0095b6196 --- /dev/null +++ b/src/platform/assets/composables/useAssetBrowserDialog.stories.ts @@ -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: ` +
+
+

Asset Browser Dialog Demo

+ +
+
+

Different Node Types

+
+ + + +
+
+ +
+

With Current Value

+ +

+ Opens with "realistic_vision_v5.safetensors" as current value +

+
+ +
+

Instructions:

+
    +
  • • Click any button to open the Asset Browser dialog
  • +
  • • Select an asset to see the callback in action
  • +
  • • Check the browser console for logged events
  • +
  • • Try toggling the left panel with different asset types
  • +
  • • Close button will work properly in this demo
  • +
+
+
+
+ + +
+
+ +
+
+
+ ` +} + +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 + +export const Demo: Story = { + render: () => ({ + components: { DialogDemoComponent }, + template: ` +
+ + + +
+

Code Example

+

+ This is how you would use the composable in your component: +

+
+
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 }
+  }
+}
+
+
+

+ 💡 Try it: Use the interactive buttons above to see this code in action! +

+
+
+
+ ` + }), + parameters: { + docs: { + description: { + story: + 'Complete demo showing both interactive functionality and code examples for using useAssetBrowserDialog to open the Asset Browser modal programmatically.' + } + } + } +} diff --git a/src/platform/assets/composables/useAssetBrowserDialog.ts b/src/platform/assets/composables/useAssetBrowserDialog.ts new file mode 100644 index 0000000000..e5f63eead3 --- /dev/null +++ b/src/platform/assets/composables/useAssetBrowserDialog.ts @@ -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 } +} diff --git a/src/platform/assets/composables/useAssetFilterOptions.ts b/src/platform/assets/composables/useAssetFilterOptions.ts new file mode 100644 index 0000000000..30572d8c9a --- /dev/null +++ b/src/platform/assets/composables/useAssetFilterOptions.ts @@ -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(() => { + 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(() => { + 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 + } +} diff --git a/src/platform/assets/fixtures/ui-mock-assets.ts b/src/platform/assets/fixtures/ui-mock-assets.ts new file mode 100644 index 0000000000..6c72843863 --- /dev/null +++ b/src/platform/assets/fixtures/ui-mock-assets.ts @@ -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(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) diff --git a/src/platform/assets/schemas/assetSchema.ts b/src/platform/assets/schemas/assetSchema.ts index 277efcbb02..fab41649af 100644 --- a/src/platform/assets/schemas/assetSchema.ts +++ b/src/platform/assets/schemas/assetSchema.ts @@ -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 export type AssetResponse = z.infer export type ModelFolder = z.infer +export type ModelFile = z.infer -// 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[] diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index 344209bf71..74b20a753a 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -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( @@ -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) ) ?? [] ) diff --git a/src/platform/assets/utils/assetMetadataUtils.ts b/src/platform/assets/utils/assetMetadataUtils.ts new file mode 100644 index 0000000000..2d32fa07fe --- /dev/null +++ b/src/platform/assets/utils/assetMetadataUtils.ts @@ -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 +} diff --git a/tailwind.config.ts b/tailwind.config.ts index e5e39987be..98c7c4ea04 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -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: { diff --git a/tests-ui/platform/assets/components/AssetBrowserModal.test.ts b/tests-ui/platform/assets/components/AssetBrowserModal.test.ts new file mode 100644 index 0000000000..4e96dd902d --- /dev/null +++ b/tests-ui/platform/assets/components/AssetBrowserModal.test.ts @@ -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: ` + + ` + } +})) + +vi.mock('@/components/widget/layout/BaseModalLayout.vue', () => ({ + default: { + name: 'BaseModalLayout', + props: ['contentTitle'], + emits: ['close'], + template: ` +
+
+ +
+
+ +
+
+ +
+
+ ` + } +})) + +vi.mock('@/components/widget/panel/LeftSidePanel.vue', () => ({ + default: { + name: 'LeftSidePanel', + props: ['modelValue', 'navItems'], + emits: ['update:modelValue'], + template: ` +
+ +
+ ` + } +})) + +vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({ + default: { + name: 'AssetGrid', + props: ['assets'], + emits: ['asset-select'], + template: ` +
+
+ {{ asset.name }} +
+
+ No assets found +
+
+ ` + } +})) + +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 = {} + ) => { + const pinia = createPinia() + setActivePinia(pinia) + + return mount(AssetBrowserModal, { + props: { + assets: assets, + ...props + }, + global: { + plugins: [pinia], + stubs: { + 'i-lucide:folder': { + template: '
' + } + }, + 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) + }) + }) +}) diff --git a/tests-ui/platform/assets/components/AssetFilterBar.test.ts b/tests-ui/platform/assets/components/AssetFilterBar.test.ts new file mode 100644 index 0000000000..db24070e1d --- /dev/null +++ b/tests-ui/platform/assets/components/AssetFilterBar.test.ts @@ -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: ` +
+ +
+ ` + } +})) + +vi.mock('@/components/input/SingleSelect.vue', () => ({ + default: { + name: 'SingleSelect', + props: { + modelValue: String, + label: String, + options: Array, + class: String + }, + emits: ['update:modelValue'], + template: ` +
+ +
+ ` + } +})) + +// 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 + ) + }) + }) +}) diff --git a/tests-ui/platform/assets/composables/useAssetBrowser.test.ts b/tests-ui/platform/assets/composables/useAssetBrowser.test.ts new file mode 100644 index 0000000000..d7d4f74dcb --- /dev/null +++ b/tests-ui/platform/assets/composables/useAssetBrowser.test.ts @@ -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 => ({ + 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') + }) + }) +}) diff --git a/tests-ui/platform/assets/composables/useAssetBrowserDialog.test.ts b/tests-ui/platform/assets/composables/useAssetBrowserDialog.test.ts new file mode 100644 index 0000000000..fefeeceaca --- /dev/null +++ b/tests-ui/platform/assets/composables/useAssetBrowserDialog.test.ts @@ -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 +} + +function createAssetBrowserProps( + overrides: Partial = {} +): 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> 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> 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' + }) + }) + }) +}) diff --git a/tests-ui/platform/assets/composables/useAssetFilterOptions.test.ts b/tests-ui/platform/assets/composables/useAssetFilterOptions.test.ts new file mode 100644 index 0000000000..8cec2ab12b --- /dev/null +++ b/tests-ui/platform/assets/composables/useAssetFilterOptions.test.ts @@ -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 { + 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) + }) + }) +}) diff --git a/tests-ui/tests/platform/assets/utils/assetMetadataUtils.test.ts b/tests-ui/tests/platform/assets/utils/assetMetadataUtils.test.ts new file mode 100644 index 0000000000..54551f5951 --- /dev/null +++ b/tests-ui/tests/platform/assets/utils/assetMetadataUtils.test.ts @@ -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() + }) + }) +}) diff --git a/tests-ui/tests/services/assetService.test.ts b/tests-ui/tests/services/assetService.test.ts index f11c9d40d3..d96ef765b9 100644 --- a/tests-ui/tests/services/assetService.test.ts +++ b/tests-ui/tests/services/assetService.test.ts @@ -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 = {}) { + 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)