[feat] call AssetBrowserModal for whitelisted widgets

This commit is contained in:
Arjan Singh
2025-09-17 15:06:16 -07:00
parent 4820d87d86
commit b67e4977d7
13 changed files with 504 additions and 54 deletions

View File

@@ -69,7 +69,7 @@ const {
availableCategories,
contentTitle,
filteredAssets,
selectAsset
selectAssetWithCallback
} = useAssetBrowser(props.assets)
// Dialog controls panel visibility via prop
@@ -84,13 +84,10 @@ const handleClose = () => {
}
// Handle asset selection and emit to parent
const handleAssetSelectAndEmit = (asset: AssetDisplayItem) => {
selectAsset(asset) // This logs the selection for dev mode
const handleAssetSelectAndEmit = async (asset: AssetDisplayItem) => {
emit('asset-select', asset) // Emit the full asset object
// Call prop callback if provided
if (props.onSelect) {
props.onSelect(asset.name) // Use asset name as the asset path
}
// Use composable for detail fetching and callback execution
await selectAssetWithCallback(asset.id, props.onSelect)
}
</script>

View File

@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
import { d, t } from '@/i18n'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import {
getAssetBaseModel,
getAssetDescription
@@ -162,12 +163,51 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
return filtered.map(transformAssetForDisplay)
})
// Actions
function selectAsset(asset: AssetDisplayItem): UUID {
/**
* Asset selection that fetches full details and executes callback with filename
* @param assetId - The asset ID to select and fetch details for
* @param onSelect - Optional callback to execute with the asset filename
*/
async function selectAssetWithCallback(
assetId: string,
onSelect?: (filename: string) => void
): Promise<void> {
// Always log selection for debugging
if (import.meta.env.DEV) {
console.log('Asset selected:', asset.id, asset.name)
console.log('Asset selected:', assetId)
}
// If no callback provided, just return (no need to fetch details)
if (!onSelect) {
return
}
try {
// Fetch complete asset details to get user_metadata
const detailAsset = await assetService.getAssetDetails(assetId)
// Extract filename from user_metadata
const filename = detailAsset.user_metadata?.filename
// Validate filename exists and is not empty
if (!filename || typeof filename !== 'string' || filename.trim() === '') {
console.error(
'Invalid asset filename from user_metadata:',
filename || null,
'for asset:',
assetId
)
return
}
// Execute callback with validated filename
onSelect(filename)
} catch (error) {
console.error(
`Failed to fetch asset details for ${assetId}:`,
error
)
}
return asset.id
}
return {
@@ -182,7 +222,6 @@ export function useAssetBrowser(assets: AssetItem[] = []) {
filteredAssets,
// Actions
selectAsset,
transformAssetForDisplay
selectAssetWithCallback
}
}

View File

@@ -1,4 +1,6 @@
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { useDialogStore } from '@/stores/dialogStore'
interface AssetBrowserDialogProps {
@@ -8,8 +10,11 @@ interface AssetBrowserDialogProps {
inputName: string
/** Current selected asset value */
currentValue?: string
/** Callback for when an asset is selected */
onAssetSelected?: (assetPath: string) => void
/**
* Callback for when an asset is selected
* @param {string} filename - The validated filename from user_metadata.filename
*/
onAssetSelected?: (filename: string) => void
}
export const useAssetBrowserDialog = () => {
@@ -20,7 +25,7 @@ export const useAssetBrowserDialog = () => {
dialogStore.closeDialog({ key: dialogKey })
}
function show(props: AssetBrowserDialogProps) {
async function show(props: AssetBrowserDialogProps) {
const handleAssetSelected = (assetPath: string) => {
props.onAssetSelected?.(assetPath)
hide() // Auto-close on selection
@@ -48,6 +53,14 @@ export const useAssetBrowserDialog = () => {
}
}
// Fetch assets for the specific node type, fallback to empty array on error
let assets: AssetItem[] = []
try {
assets = await assetService.getAssetsForNodeType(props.nodeType)
} catch (error) {
console.error('Failed to fetch assets for node type:', props.nodeType, error)
}
dialogStore.showDialog({
key: dialogKey,
component: AssetBrowserModal,
@@ -55,6 +68,7 @@ export const useAssetBrowserDialog = () => {
nodeType: props.nodeType,
inputName: props.inputName,
currentValue: props.currentValue,
assets,
onSelect: handleAssetSelected,
onClose: handleClose
},

View File

@@ -6,11 +6,11 @@ const zAsset = z.object({
name: z.string(),
asset_hash: z.string(),
size: z.number(),
mime_type: z.string(),
mime_type: z.string().nullable(),
tags: z.array(z.string()),
preview_url: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
updated_at: z.string().optional(),
last_access_time: z.string(),
user_metadata: z.record(z.unknown()).optional(), // API allows arbitrary key-value pairs
preview_id: z.string().nullable().optional()

View File

@@ -1,6 +1,7 @@
import { fromZodError } from 'zod-validation-error'
import {
type AssetItem,
type AssetResponse,
type ModelFile,
type ModelFolder,
@@ -127,10 +128,74 @@ function createAssetService() {
)
}
/**
* Gets assets for a specific node type by finding the matching category
* and fetching all assets with that category tag
*
* @param nodeType - The ComfyUI node type (e.g., 'CheckpointLoaderSimple')
* @returns Promise<AssetItem[]> - Full asset objects with preserved metadata
*/
async function getAssetsForNodeType(nodeType: string): Promise<AssetItem[]> {
if (!nodeType || typeof nodeType !== 'string') {
return []
}
// Find the category for this node type by reverse lookup in modelToNodeMap
const modelToNodeStore = useModelToNodeStore()
const modelToNodeMap = modelToNodeStore.modelToNodeMap
const category = Object.keys(modelToNodeMap).find(categoryKey =>
modelToNodeMap[categoryKey].some(provider => provider.nodeDef.name === nodeType)
)
if (!category) {
return []
}
// Fetch assets for this category using same API pattern as getAssetModels
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}`,
`assets for ${nodeType}`
)
// Return full AssetItem[] objects (don't strip like getAssetModels does)
return data?.assets?.filter(asset =>
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(category)
) ?? []
}
/**
* Gets complete details for a specific asset by ID
* Calls the detail endpoint which includes user_metadata and all fields
*
* @param id - The asset ID
* @returns Promise<AssetItem> - Complete asset object with user_metadata
*/
async function getAssetDetails(id: string): Promise<AssetItem> {
const res = await api.fetchApi(`${ASSETS_ENDPOINT}/${id}`)
if (!res.ok) {
throw new Error(
`Unable to load asset details for ${id}: Server returned ${res.status}. Please try again.`
)
}
const data = await res.json()
// Validate the single asset response against our schema
const result = assetResponseSchema.safeParse({ assets: [data] })
if (result.success && result.data.assets?.[0]) {
return result.data.assets[0]
}
const error = fromZodError(result.error)
throw new Error(`Invalid asset response against zod schema:\n${error}`)
}
return {
getAssetModelFolders,
getAssetModels,
isAssetBrowserEligible
isAssetBrowserEligible,
getAssetsForNodeType,
getAssetDetails
}
}

View File

@@ -25,3 +25,38 @@ export function getAssetBaseModel(asset: AssetItem): string | null {
? asset.user_metadata.base_model
: null
}
/**
* Safely extracts the ComfyUI-relative filename from user_metadata.
* @param {import('../schemas/assetSchema').AssetItem} asset - The asset item containing user_metadata
* @returns {string | null} ComfyUI-relative path or null if not available
*/
export function getAssetFilename(asset: AssetItem): string | null {
const filename = asset.user_metadata?.filename
if (typeof filename !== 'string' || !filename.trim()) {
return null
}
return filename.trim()
}
/**
* Validates if a filename path is safe for ComfyUI widget usage.
* @param {string} filename - The filename to validate
* @returns {boolean} True if filename is safe for widget usage
*/
export function validateAssetFilename(filename: string): boolean {
if (!filename || typeof filename !== 'string') return false
const trimmed = filename.trim()
if (!trimmed) return false
// Reject dangerous patterns but allow forward slashes for subdirectories
// e.g., reject "../../../etc/passwd" but allow "checkpoints/model.safetensors"
if (trimmed.includes('..') || /[<>:"|?*]/.test(trimmed)) {
return false
}
return true
}

View File

@@ -4,9 +4,11 @@ import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IAssetWidget,
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
@@ -73,10 +75,21 @@ const addComboWidget = (
const currentValue = getDefaultValue(inputSpec)
const displayLabel = currentValue ?? t('widgets.selectModel')
const widget = node.addWidget('asset', inputSpec.name, displayLabel, () => {
console.log(
`Asset Browser would open here for:\nNode: ${node.type}\nWidget: ${inputSpec.name}\nCurrent Value:${currentValue}`
)
const assetBrowserDialog = useAssetBrowserDialog()
const widget = node.addWidget('asset', inputSpec.name, displayLabel, async () => {
const assetWidget = widget as IAssetWidget
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: assetWidget.value,
onAssetSelected: (filename: string) => {
assetWidget.value = filename
// Must call widget.callback to notify litegraph of value changes
// This ensures proper serialization and triggers any downstream effects
assetWidget.callback?.(assetWidget.value)
}
})
})
return widget

View File

@@ -6,6 +6,14 @@ 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'
import { assetService } from '@/platform/assets/services/assetService'
// Mock assetService
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetDetails: vi.fn()
}
}))
// Mock external dependencies with minimal functionality needed for business logic tests
vi.mock('@/components/input/SearchBox.vue', () => ({
@@ -92,9 +100,18 @@ vi.mock('@/platform/assets/components/AssetGrid.vue', () => ({
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}),
createI18n: vi.fn()
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key,
d: (date: Date, options?: any) => date.toLocaleDateString()
}))
// Mock console.error for error handling tests
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
describe('AssetBrowserModal', () => {
const createTestAsset = (
id: string,
@@ -255,18 +272,9 @@ describe('AssetBrowserModal', () => {
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 = [

View File

@@ -1,10 +1,33 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi, beforeEach } 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, options?: any) => date.toLocaleDateString()
}))
describe('useAssetBrowser', () => {
beforeEach(() => {
vi.resetAllMocks()
})
// Test fixtures - minimal data focused on functionality being tested
const createApiAsset = (overrides: Partial<AssetItem> = {}): AssetItem => ({
id: 'test-id',
@@ -26,8 +49,8 @@ describe('useAssetBrowser', () => {
user_metadata: { description: 'Test model' }
})
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
const result = transformAssetForDisplay(apiAsset)
const { filteredAssets } = useAssetBrowser([apiAsset])
const result = filteredAssets.value[0] // Get the transformed asset from filteredAssets
// Preserves API properties
expect(result.id).toBe(apiAsset.id)
@@ -49,15 +72,13 @@ describe('useAssetBrowser', () => {
user_metadata: undefined
})
const { transformAssetForDisplay } = useAssetBrowser([apiAsset])
const result = transformAssetForDisplay(apiAsset)
const { filteredAssets } = useAssetBrowser([apiAsset])
const result = filteredAssets.value[0]
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' },
@@ -67,7 +88,8 @@ describe('useAssetBrowser', () => {
testCases.forEach(({ size, expected }) => {
const asset = createApiAsset({ size })
const result = transformAssetForDisplay(asset)
const { filteredAssets } = useAssetBrowser([asset])
const result = filteredAssets.value[0]
expect(result.formattedSize).toBe(expected)
})
})
@@ -236,18 +258,87 @@ describe('useAssetBrowser', () => {
})
})
describe('Asset Selection', () => {
it('returns selected asset UUID for efficient handling', () => {
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: 'test-uuid-123',
name: 'selected_model.safetensors'
id: 'asset-123',
name: 'test-model.safetensors'
})
const { selectAsset, transformAssetForDisplay } = useAssetBrowser([asset])
const displayAsset = transformAssetForDisplay(asset)
const result = selectAsset(displayAsset)
const detailAsset = createApiAsset({
id: 'asset-123',
name: 'test-model.safetensors',
user_metadata: { filename: 'checkpoints/test-model.safetensors' }
})
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
expect(result).toBe('test-uuid-123')
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 from user_metadata:',
null,
'for asset:',
'asset-456'
)
consoleErrorSpy.mockRestore()
})
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
)
consoleErrorSpy.mockRestore()
})
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()
})
})

View File

@@ -10,7 +10,7 @@ vi.mock('@/stores/dialogStore')
interface AssetBrowserProps {
nodeType: string
inputName: string
onAssetSelected?: ReturnType<typeof vi.fn>
onAssetSelected?: (filename: string) => void
}
function createAssetBrowserProps(
@@ -85,5 +85,6 @@ describe('useAssetBrowserDialog', () => {
key: 'global-asset-browser'
})
})
})
})

View File

@@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getAssetBaseModel,
getAssetDescription
getAssetDescription,
getAssetFilename,
validateAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
describe('assetMetadataUtils', () => {
@@ -62,4 +64,50 @@ describe('assetMetadataUtils', () => {
expect(getAssetBaseModel(mockAsset)).toBeNull()
})
})
describe('getAssetFilename', () => {
it('should return trimmed filename when present', () => {
const asset = {
...mockAsset,
user_metadata: { filename: ' checkpoints/model.safetensors ' }
}
expect(getAssetFilename(asset)).toBe('checkpoints/model.safetensors')
})
it('should return null when filename is empty string', () => {
const asset = {
...mockAsset,
user_metadata: { filename: ' ' }
}
expect(getAssetFilename(asset)).toBeNull()
})
it('should return null when no metadata', () => {
expect(getAssetFilename(mockAsset)).toBeNull()
})
})
describe('validateAssetFilename', () => {
it('should accept valid filenames', () => {
expect(validateAssetFilename('model.safetensors')).toBe(true)
expect(validateAssetFilename('checkpoints/model.safetensors')).toBe(true)
expect(validateAssetFilename('loras/style/anime.safetensors')).toBe(true)
})
it('should reject directory traversal attempts', () => {
expect(validateAssetFilename('../../../etc/passwd')).toBe(false)
expect(validateAssetFilename('models/../../../secret.txt')).toBe(false)
})
it('should reject dangerous characters', () => {
expect(validateAssetFilename('model<script>.safetensors')).toBe(false)
expect(validateAssetFilename('model|pipe.safetensors')).toBe(false)
expect(validateAssetFilename('model*wildcard.safetensors')).toBe(false)
})
it('should reject empty or whitespace-only strings', () => {
expect(validateAssetFilename('')).toBe(false)
expect(validateAssetFilename(' ')).toBe(false)
})
})
})

View File

@@ -29,13 +29,23 @@ vi.mock('@/platform/assets/services/assetService', () => ({
}
}))
const mockAssetBrowserDialogShow = vi.fn()
vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => ({
useAssetBrowserDialog: vi.fn(() => ({
show: mockAssetBrowserDialogShow
}))
}))
// Test factory functions
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
const mockCallback = vi.fn()
return {
type: 'combo',
options: {},
name: 'testWidget',
value: undefined,
callback: mockCallback,
y: 0,
...overrides
} as IBaseWidget
}
@@ -45,7 +55,14 @@ function createMockNode(comfyClass = 'TestNode'): LGraphNode {
node.comfyClass = comfyClass
// Spy on the addWidget method
vi.spyOn(node, 'addWidget').mockReturnValue(createMockWidget())
vi.spyOn(node, 'addWidget').mockImplementation((type, name, value, callback) => {
const widget = createMockWidget({ type, name, value })
// Store the callback function on the widget for testing
if (typeof callback === 'function') {
widget.callback = callback
}
return widget
})
return node
}
@@ -64,6 +81,7 @@ describe('useComboWidget', () => {
// Reset to defaults
mockSettingStoreGet.mockReturnValue(false)
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
mockAssetBrowserDialogShow.mockClear()
})
it('should handle undefined spec', () => {
@@ -275,4 +293,5 @@ describe('useComboWidget', () => {
)
expect(widget).toBe(mockWidget)
})
})

View File

@@ -14,7 +14,13 @@ vi.mock('@/stores/modelToNodeStore', () => ({
'VAELoader',
'TestNode'
])
)
),
modelToNodeMap: {
'test-category': [{ nodeDef: { name: 'TestNode' }, key: 'test_input' }],
'other-category': [{ nodeDef: { name: 'OtherNode' }, key: 'other_input' }],
'checkpoints': [{ nodeDef: { name: 'CheckpointLoaderSimple' }, key: 'ckpt_name' }],
'loras': [{ nodeDef: { name: 'LoraLoader' }, key: 'lora_name' }]
}
}))
}))
@@ -210,4 +216,118 @@ describe('assetService', () => {
).toBe(false)
})
})
describe('getAssetsForNodeType', () => {
it('should return assets for registered node type', async () => {
const testAssets = [
createTestAsset({
id: 'uuid-1',
name: 'test-asset.ext',
user_metadata: { filename: 'test-asset.ext' },
tags: ['models', 'test-category']
})
]
mockApiResponse(testAssets)
const assets = await assetService.getAssetsForNodeType('TestNode')
expect(api.fetchApi).toHaveBeenCalledWith('/assets?include_tags=models,test-category')
expect(assets).toEqual(testAssets)
})
it('should return empty array for unregistered node type', async () => {
const assets = await assetService.getAssetsForNodeType('UnknownNode')
expect(assets).toEqual([])
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('should return empty array for empty string', async () => {
const assets = await assetService.getAssetsForNodeType('')
expect(assets).toEqual([])
expect(api.fetchApi).not.toHaveBeenCalled()
})
it('should handle API errors gracefully', async () => {
mockApiError(500)
await expect(assetService.getAssetsForNodeType('TestNode')).rejects.toThrow(
'Unable to load assets for TestNode: Server returned 500. Please try again.'
)
})
it('should preserve full AssetItem structure with user_metadata', async () => {
const testAssets = [
createTestAsset({
id: 'uuid-1',
name: 'test-asset.ext',
user_metadata: { filename: 'test-asset.ext' },
tags: ['models', 'test-category']
})
]
mockApiResponse(testAssets)
const assets = await assetService.getAssetsForNodeType('TestNode')
expect(assets[0]).toHaveProperty('user_metadata.filename', 'test-asset.ext')
expect(assets[0]).toHaveProperty('id', 'uuid-1')
expect(assets[0]).toHaveProperty('tags')
})
})
describe('getAssetDetails', () => {
it('should fetch complete asset details by ID', async () => {
const assetWithDetails = createTestAsset({
id: 'asset-123',
name: 'detailed-asset.safetensors',
user_metadata: { filename: 'checkpoints/detailed-asset.safetensors' }
})
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () => Promise.resolve(assetWithDetails)
} as Response)
const asset = await assetService.getAssetDetails('asset-123')
expect(api.fetchApi).toHaveBeenCalledWith('/assets/asset-123')
expect(asset).toEqual(assetWithDetails)
})
it('should return asset with user_metadata.filename', async () => {
const assetWithDetails = createTestAsset({
id: 'asset-456',
user_metadata: { filename: 'loras/test-lora.safetensors' }
})
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () => Promise.resolve(assetWithDetails)
} as Response)
const asset = await assetService.getAssetDetails('asset-456')
expect(asset.user_metadata?.filename).toBe('loras/test-lora.safetensors')
})
it('should throw error when API returns 404', async () => {
vi.mocked(api.fetchApi).mockResolvedValue({
ok: false,
status: 404
} as Response)
await expect(assetService.getAssetDetails('nonexistent-id')).rejects.toThrow(
'Unable to load asset details for nonexistent-id: Server returned 404. Please try again.'
)
})
it('should throw error when response fails schema validation', async () => {
// Return invalid asset data that fails schema validation
vi.mocked(api.fetchApi).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ invalid: 'data' })
} as Response)
await expect(assetService.getAssetDetails('invalid-asset')).rejects.toThrow(
/Invalid asset response against zod schema/
)
})
})
})