merge main into rh-test

This commit is contained in:
bymyself
2025-09-28 15:33:29 -07:00
parent 1c0f151d02
commit ff0c15b119
1317 changed files with 85439 additions and 18373 deletions

View File

@@ -0,0 +1,330 @@
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 @/i18n for useAssetBrowser and AssetFilterBar
vi.mock('@/i18n', () => ({
t: (key: string) => key,
d: (date: Date) => date.toLocaleDateString()
}))
// Mock assetService for useAssetBrowser
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetDetails: vi.fn((id: string) =>
Promise.resolve({
id,
name: 'Test Model',
user_metadata: {
filename: 'Test Model'
}
})
)
}
}))
// 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
}),
createI18n: () => ({
global: {
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,268 @@
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'
import {
createAssetWithSpecificBaseModel,
createAssetWithSpecificExtension,
createAssetWithoutBaseModel
} from '@/platform/assets/fixtures/ui-mock-assets'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
// Mock @/i18n directly since component imports { t } from '@/i18n'
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
// 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
function mountAssetFilterBar(props = {}) {
return mount(AssetFilterBar, {
props,
global: {
mocks: {
$t: (key: string) => key
}
}
})
}
describe('AssetFilterBar', () => {
describe('Filter State Management', () => {
it('maintains correct initial state', () => {
// Provide assets with options so filters are visible
const assets = [
createAssetWithSpecificExtension('safetensors'),
createAssetWithSpecificBaseModel('sd15')
]
const wrapper = mountAssetFilterBar({ assets })
// 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 () => {
// Provide assets with options so filters are visible
const assets = [
createAssetWithSpecificExtension('safetensors'),
createAssetWithSpecificBaseModel('sd15')
]
const wrapper = mountAssetFilterBar({ assets })
// 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 () => {
// Provide assets with options so filters are visible
const assets = [
createAssetWithSpecificExtension('safetensors'),
createAssetWithSpecificBaseModel('sd15')
]
const wrapper = mountAssetFilterBar({ assets })
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
)
})
})
describe('Dynamic Filter Options', () => {
it('should use dynamic file format options based on actual assets', () => {
const assets = [
createAssetWithSpecificExtension('safetensors'),
createAssetWithSpecificExtension('ckpt'),
createAssetWithSpecificExtension('pt')
]
const wrapper = mountAssetFilterBar({ assets })
const fileFormatSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[0]
expect(fileFormatSelect.props('options')).toEqual([
{ name: '.ckpt', value: 'ckpt' },
{ name: '.pt', value: 'pt' },
{ name: '.safetensors', value: 'safetensors' }
])
})
it('should use dynamic base model options based on actual assets', () => {
const assets = [
createAssetWithSpecificBaseModel('sd15'),
createAssetWithSpecificBaseModel('sdxl'),
createAssetWithSpecificBaseModel('sd35')
]
const wrapper = mountAssetFilterBar({ assets })
const baseModelSelect = wrapper.findAllComponents({
name: 'MultiSelect'
})[1]
expect(baseModelSelect.props('options')).toEqual([
{ name: 'sd15', value: 'sd15' },
{ name: 'sd35', value: 'sd35' },
{ name: 'sdxl', value: 'sdxl' }
])
})
})
describe('Conditional Filter Visibility', () => {
it('hides file format filter when no options available', () => {
const assets: AssetItem[] = [] // No assets = no file format options
const wrapper = mountAssetFilterBar({ assets })
const fileFormatSelects = wrapper
.findAllComponents({ name: 'MultiSelect' })
.filter(
(component) => component.props('label') === 'assetBrowser.fileFormats'
)
expect(fileFormatSelects).toHaveLength(0)
})
it('hides base model filter when no options available', () => {
const assets = [createAssetWithoutBaseModel()] // Asset without base model = no base model options
const wrapper = mountAssetFilterBar({ assets })
const baseModelSelects = wrapper
.findAllComponents({ name: 'MultiSelect' })
.filter(
(component) => component.props('label') === 'assetBrowser.baseModels'
)
expect(baseModelSelects).toHaveLength(0)
})
it('shows both filters when options are available', () => {
const assets = [
createAssetWithSpecificExtension('safetensors'),
createAssetWithSpecificBaseModel('sd15')
]
const wrapper = mountAssetFilterBar({ assets })
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
const fileFormatSelect = multiSelects.find(
(component) => component.props('label') === 'assetBrowser.fileFormats'
)
const baseModelSelect = multiSelects.find(
(component) => component.props('label') === 'assetBrowser.baseModels'
)
expect(fileFormatSelect).toBeDefined()
expect(baseModelSelect).toBeDefined()
})
it('hides both filters when no assets provided', () => {
const wrapper = mountAssetFilterBar()
const multiSelects = wrapper.findAllComponents({ name: 'MultiSelect' })
expect(multiSelects).toHaveLength(0)
})
})
})

View File

@@ -0,0 +1,509 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetDetails: vi.fn()
}
}))
vi.mock('@/i18n', () => ({
t: (key: string) => {
const translations: Record<string, string> = {
'assetBrowser.allModels': 'All Models',
'assetBrowser.assets': 'Assets',
'assetBrowser.unknown': 'unknown'
}
return translations[key] || key
},
d: (date: Date) => date.toLocaleDateString()
}))
describe('useAssetBrowser', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
// 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 { filteredAssets } = useAssetBrowser([apiAsset])
const result = filteredAssets.value[0] // Get the transformed asset from filteredAssets
// 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 { filteredAssets } = useAssetBrowser([apiAsset])
const result = filteredAssets.value[0]
expect(result.description).toBe('loras model')
})
it('formats various file sizes correctly', () => {
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 { filteredAssets } = useAssetBrowser([asset])
const result = filteredAssets.value[0]
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 { updateFilters, filteredAssets } = useAssetBrowser(assets)
updateFilters({ sortBy: 'name', fileFormats: [], baseModels: [] })
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 { updateFilters, filteredAssets } = useAssetBrowser(assets)
updateFilters({ sortBy: 'recent', fileFormats: [], baseModels: [] })
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('Async Asset Selection with Detail Fetching', () => {
it('should fetch asset details and call onSelect with filename when provided', async () => {
const onSelectSpy = vi.fn()
const asset = createApiAsset({
id: 'asset-123',
name: 'test-model.safetensors'
})
const detailAsset = createApiAsset({
id: 'asset-123',
name: 'test-model.safetensors',
user_metadata: { filename: 'checkpoints/test-model.safetensors' }
})
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([asset])
await selectAssetWithCallback(asset.id, onSelectSpy)
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-123')
expect(onSelectSpy).toHaveBeenCalledWith(
'checkpoints/test-model.safetensors'
)
})
it('should handle missing user_metadata.filename as error', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const onSelectSpy = vi.fn()
const asset = createApiAsset({ id: 'asset-456' })
const detailAsset = createApiAsset({
id: 'asset-456',
user_metadata: { filename: '' } // Invalid empty filename
})
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([asset])
await selectAssetWithCallback(asset.id, onSelectSpy)
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-456')
expect(onSelectSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Invalid asset filename:',
expect.arrayContaining([
expect.objectContaining({
message: 'Filename cannot be empty'
})
]),
'for asset:',
'asset-456'
)
})
it('should handle API errors gracefully', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const onSelectSpy = vi.fn()
const asset = createApiAsset({ id: 'asset-789' })
const apiError = new Error('API Error')
vi.mocked(assetService.getAssetDetails).mockRejectedValue(apiError)
const { selectAssetWithCallback } = useAssetBrowser([asset])
await selectAssetWithCallback(asset.id, onSelectSpy)
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-789')
expect(onSelectSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to fetch asset details for asset-789'),
apiError
)
})
it('should not fetch details when no callback provided', async () => {
const asset = createApiAsset({ id: 'asset-no-callback' })
const { selectAssetWithCallback } = useAssetBrowser([asset])
await selectAssetWithCallback(asset.id)
expect(assetService.getAssetDetails).not.toHaveBeenCalled()
})
})
describe('Filename Validation Security', () => {
const createValidationTest = (filename: string) => {
const testAsset = createApiAsset({ id: 'validation-test' })
const detailAsset = createApiAsset({
id: 'validation-test',
user_metadata: { filename }
})
return { testAsset, detailAsset }
}
it('accepts valid file paths with forward slashes', async () => {
const onSelectSpy = vi.fn()
const { testAsset, detailAsset } = createValidationTest(
'models/checkpoints/v1/test-model.safetensors'
)
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
await selectAssetWithCallback(testAsset.id, onSelectSpy)
expect(onSelectSpy).toHaveBeenCalledWith(
'models/checkpoints/v1/test-model.safetensors'
)
})
it('rejects directory traversal attacks', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const onSelectSpy = vi.fn()
const maliciousPaths = [
'../malicious-model.safetensors',
'models/../../../etc/passwd',
'/etc/passwd'
]
for (const path of maliciousPaths) {
const { testAsset, detailAsset } = createValidationTest(path)
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
await selectAssetWithCallback(testAsset.id, onSelectSpy)
expect(onSelectSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Invalid asset filename:',
expect.arrayContaining([
expect.objectContaining({
message: 'Path must not start with / or contain ..'
})
]),
'for asset:',
'validation-test'
)
}
})
it('rejects invalid filename characters', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const onSelectSpy = vi.fn()
const invalidChars = ['\\', ':', '*', '?', '"', '<', '>', '|']
for (const char of invalidChars) {
const { testAsset, detailAsset } = createValidationTest(
`bad${char}filename.safetensors`
)
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
await selectAssetWithCallback(testAsset.id, onSelectSpy)
expect(onSelectSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Invalid asset filename:',
expect.arrayContaining([
expect.objectContaining({
message: 'Invalid filename characters'
})
]),
'for asset:',
'validation-test'
)
}
})
})
describe('Dynamic Category Extraction', () => {
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,96 @@
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')
// Mock the asset service
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetsForNodeType: vi.fn().mockResolvedValue([])
}
}))
// Test factory functions
interface AssetBrowserProps {
nodeType: string
inputName: string
onAssetSelected?: (filename: string) => void
}
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', async () => {
// 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 })
await 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 trigger hide animation
expect(onAssetSelected).toHaveBeenCalledWith('selected-asset-path')
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'global-asset-browser'
})
})
it('closes dialog when close handler is called', async () => {
// 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()
await 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,146 @@
import { describe, expect, it } from 'vitest'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import {
createAssetWithSpecificBaseModel,
createAssetWithSpecificExtension,
createAssetWithoutBaseModel,
createAssetWithoutExtension,
createAssetWithoutUserMetadata
} from '@/platform/assets/fixtures/ui-mock-assets'
describe('useAssetFilterOptions', () => {
describe('File Format Extraction', () => {
it('extracts file formats from asset names', () => {
const assets = [
createAssetWithSpecificExtension('safetensors'),
createAssetWithSpecificExtension('ckpt'),
createAssetWithSpecificExtension('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 = [
createAssetWithSpecificExtension('safetensors'),
createAssetWithSpecificExtension('safetensors'),
createAssetWithSpecificExtension('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 = [
createAssetWithoutExtension(),
createAssetWithSpecificExtension('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 = [
createAssetWithSpecificBaseModel('sd15'),
createAssetWithSpecificBaseModel('sdxl'),
createAssetWithSpecificBaseModel('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 = [
createAssetWithSpecificBaseModel('sd15'),
createAssetWithSpecificBaseModel('sd15'),
createAssetWithSpecificBaseModel('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 = [
createAssetWithoutUserMetadata(),
createAssetWithSpecificBaseModel('sd15')
]
const { availableBaseModels } = useAssetFilterOptions(assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sd15', value: 'sd15' }
])
})
it('handles assets with missing base_model field', () => {
const assets = [
createAssetWithoutBaseModel(),
createAssetWithSpecificBaseModel('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 = [createAssetWithSpecificExtension('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)
})
})
})