Merge main (as of 10-06-2025) into rh-test (#5965)

## Summary

Merges latest changes from `main` as of 10-06-2025.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5965-Merge-main-as-of-10-06-2025-into-rh-test-2856d73d3650812cb95fd8917278a770)
by [Unito](https://www.unito.io)

---------

Signed-off-by: Marcel Petrick <mail@marcelpetrick.it>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: Terry Jia <terryjia88@gmail.com>
Co-authored-by: snomiao <snomiao@gmail.com>
Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: Jake Schroeder <jake.schroeder@isophex.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: AustinMroz <4284322+AustinMroz@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
Co-authored-by: Marcel Petrick <mail@marcelpetrick.it>
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: JakeSchroeder <jake@axiom.co>
Co-authored-by: AustinMroz <austin@comfy.org>
Co-authored-by: DrJKL <DrJKL@users.noreply.github.com>
Co-authored-by: ComfyUI Wiki <contact@comfyui-wiki.com>
This commit is contained in:
Arjan Singh
2025-10-08 19:06:40 -07:00
committed by GitHub
parent 529a4de583
commit 5869b04e57
454 changed files with 32333 additions and 37002 deletions

View File

@@ -3,13 +3,6 @@ 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) => {
@@ -42,6 +35,38 @@ describe('useAssetBrowser', () => {
...overrides
})
describe('Category Filtering', () => {
it('exposes category-filtered assets for filter options', () => {
const checkpointAsset = createApiAsset({
id: 'checkpoint-1',
name: 'model.safetensors',
tags: ['models', 'checkpoints']
})
const loraAsset = createApiAsset({
id: 'lora-1',
name: 'lora.pt',
tags: ['models', 'loras']
})
const { selectedCategory, categoryFilteredAssets } = useAssetBrowser([
checkpointAsset,
loraAsset
])
// Initially should show all assets
expect(categoryFilteredAssets.value).toHaveLength(2)
// When category selected, should only show that category
selectedCategory.value = 'checkpoints'
expect(categoryFilteredAssets.value).toHaveLength(1)
expect(categoryFilteredAssets.value[0].id).toBe('checkpoint-1')
selectedCategory.value = 'loras'
expect(categoryFilteredAssets.value).toHaveLength(1)
expect(categoryFilteredAssets.value[0].id).toBe('lora-1')
})
})
describe('Asset Transformation', () => {
it('transforms API asset to include display properties', () => {
const apiAsset = createApiAsset({
@@ -258,185 +283,6 @@ describe('useAssetBrowser', () => {
})
})
describe('Async Asset Selection with Detail Fetching', () => {
it('should fetch asset details and call onSelect with filename when provided', async () => {
const onSelectSpy = vi.fn()
const asset = createApiAsset({
id: 'asset-123',
name: 'test-model.safetensors'
})
const detailAsset = createApiAsset({
id: 'asset-123',
name: 'test-model.safetensors',
user_metadata: { filename: 'checkpoints/test-model.safetensors' }
})
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([asset])
await selectAssetWithCallback(asset.id, onSelectSpy)
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-123')
expect(onSelectSpy).toHaveBeenCalledWith(
'checkpoints/test-model.safetensors'
)
})
it('should handle missing user_metadata.filename as error', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const onSelectSpy = vi.fn()
const asset = createApiAsset({ id: 'asset-456' })
const detailAsset = createApiAsset({
id: 'asset-456',
user_metadata: { filename: '' } // Invalid empty filename
})
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([asset])
await selectAssetWithCallback(asset.id, onSelectSpy)
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-456')
expect(onSelectSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Invalid asset filename:',
expect.arrayContaining([
expect.objectContaining({
message: 'Filename cannot be empty'
})
]),
'for asset:',
'asset-456'
)
})
it('should handle API errors gracefully', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const onSelectSpy = vi.fn()
const asset = createApiAsset({ id: 'asset-789' })
const apiError = new Error('API Error')
vi.mocked(assetService.getAssetDetails).mockRejectedValue(apiError)
const { selectAssetWithCallback } = useAssetBrowser([asset])
await selectAssetWithCallback(asset.id, onSelectSpy)
expect(assetService.getAssetDetails).toHaveBeenCalledWith('asset-789')
expect(onSelectSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to fetch asset details for asset-789'),
apiError
)
})
it('should not fetch details when no callback provided', async () => {
const asset = createApiAsset({ id: 'asset-no-callback' })
const { selectAssetWithCallback } = useAssetBrowser([asset])
await selectAssetWithCallback(asset.id)
expect(assetService.getAssetDetails).not.toHaveBeenCalled()
})
})
describe('Filename Validation Security', () => {
const createValidationTest = (filename: string) => {
const testAsset = createApiAsset({ id: 'validation-test' })
const detailAsset = createApiAsset({
id: 'validation-test',
user_metadata: { filename }
})
return { testAsset, detailAsset }
}
it('accepts valid file paths with forward slashes', async () => {
const onSelectSpy = vi.fn()
const { testAsset, detailAsset } = createValidationTest(
'models/checkpoints/v1/test-model.safetensors'
)
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
await selectAssetWithCallback(testAsset.id, onSelectSpy)
expect(onSelectSpy).toHaveBeenCalledWith(
'models/checkpoints/v1/test-model.safetensors'
)
})
it('rejects directory traversal attacks', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const onSelectSpy = vi.fn()
const maliciousPaths = [
'../malicious-model.safetensors',
'models/../../../etc/passwd',
'/etc/passwd'
]
for (const path of maliciousPaths) {
const { testAsset, detailAsset } = createValidationTest(path)
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
await selectAssetWithCallback(testAsset.id, onSelectSpy)
expect(onSelectSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Invalid asset filename:',
expect.arrayContaining([
expect.objectContaining({
message: 'Path must not start with / or contain ..'
})
]),
'for asset:',
'validation-test'
)
}
})
it('rejects invalid filename characters', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const onSelectSpy = vi.fn()
const invalidChars = ['\\', ':', '*', '?', '"', '<', '>', '|']
for (const char of invalidChars) {
const { testAsset, detailAsset } = createValidationTest(
`bad${char}filename.safetensors`
)
vi.mocked(assetService.getAssetDetails).mockResolvedValue(detailAsset)
const { selectAssetWithCallback } = useAssetBrowser([testAsset])
await selectAssetWithCallback(testAsset.id, onSelectSpy)
expect(onSelectSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Invalid asset filename:',
expect.arrayContaining([
expect.objectContaining({
message: 'Invalid filename characters'
})
]),
'for asset:',
'validation-test'
)
}
})
})
describe('Dynamic Category Extraction', () => {
it('extracts categories from asset tags', () => {
const assets = [

View File

@@ -1,91 +1,100 @@
import { describe, expect, it, vi } from 'vitest'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
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([])
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, string>) => {
if (params) {
return `${key}:${JSON.stringify(params)}`
}
return key
}
}))
// Test factory functions
interface AssetBrowserProps {
nodeType: string
inputName: string
onAssetSelected?: (filename: string) => void
}
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
getAssetsForNodeType: vi.fn().mockResolvedValue([]),
getAssetsByTag: vi.fn().mockResolvedValue([])
}
}))
function createAssetBrowserProps(
overrides: Partial<AssetBrowserProps> = {}
): AssetBrowserProps {
const { assetService } = await import('@/platform/assets/services/assetService')
const mockGetAssetsByTag = vi.mocked(assetService.getAssetsByTag)
const mockGetAssetsForNodeType = vi.mocked(assetService.getAssetsForNodeType)
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
id: 'asset-123',
name: 'test-model.safetensors',
size: 1024,
created_at: '2025-10-01T00:00:00Z',
tags: ['models', 'checkpoints'],
user_metadata: {
filename: 'models/checkpoints/test-model.safetensors'
},
...overrides
}
}
function setupDialogMocks() {
const mockShowDialog = vi.fn()
const mockCloseDialog = vi.fn()
vi.mocked(useDialogStore, { partial: true }).mockReturnValue({
showDialog: mockShowDialog,
closeDialog: mockCloseDialog
})
return { mockShowDialog, mockCloseDialog }
}
describe('useAssetBrowserDialog', () => {
describe('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 { mockShowDialog, mockCloseDialog } = setupDialogMocks()
const assetBrowserDialog = useAssetBrowserDialog()
const onAssetSelected = vi.fn()
const props = createAssetBrowserProps({ onAssetSelected })
await assetBrowserDialog.show(props)
await assetBrowserDialog.show({
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name',
onAssetSelected
})
// Get the onSelect handler that was passed to the dialog
const dialogCall = mockShowDialog.mock.calls[0][0]
const onSelectHandler = dialogCall.props.onSelect
// Simulate asset selection
onSelectHandler('selected-asset-path')
const mockAsset = {
id: 'test-asset-id',
name: 'test.safetensors',
size: 1024,
created_at: '2025-10-01T00:00:00Z',
tags: ['models', 'checkpoints'],
user_metadata: { filename: 'selected-asset-path' }
}
onSelectHandler(mockAsset)
// Should call the original callback and trigger hide animation
expect(onAssetSelected).toHaveBeenCalledWith('selected-asset-path')
expect(onAssetSelected).toHaveBeenCalledWith(mockAsset)
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 { mockShowDialog, mockCloseDialog } = setupDialogMocks()
const assetBrowserDialog = useAssetBrowserDialog()
const props = createAssetBrowserProps()
await assetBrowserDialog.show(props)
await assetBrowserDialog.show({
nodeType: 'CheckpointLoaderSimple',
inputName: 'ckpt_name'
})
// Get the onClose handler that was passed to the dialog
const dialogCall = mockShowDialog.mock.calls[0][0]
const onCloseHandler = dialogCall.props.onClose
// Simulate dialog close
onCloseHandler()
expect(mockCloseDialog).toHaveBeenCalledWith({
@@ -93,4 +102,158 @@ describe('useAssetBrowserDialog', () => {
})
})
})
describe('.browse() method', () => {
it('opens asset browser dialog with tag-based filtering', async () => {
const { mockShowDialog } = setupDialogMocks()
const assetBrowserDialog = useAssetBrowserDialog()
await assetBrowserDialog.browse({
assetType: 'models',
title: 'Model Library'
})
expect(mockShowDialog).toHaveBeenCalledWith(
expect.objectContaining({
key: 'global-asset-browser',
props: expect.objectContaining({
showLeftPanel: true
})
})
)
})
it('calls onAssetSelected callback when asset is selected', async () => {
const { mockShowDialog } = setupDialogMocks()
const assetBrowserDialog = useAssetBrowserDialog()
const mockAsset = createMockAsset()
const onAssetSelected = vi.fn()
await assetBrowserDialog.browse({
assetType: 'models',
onAssetSelected
})
const dialogCall = mockShowDialog.mock.calls[0][0]
const onSelectHandler = dialogCall.props.onSelect
onSelectHandler(mockAsset)
expect(onAssetSelected).toHaveBeenCalledWith(mockAsset)
})
it('closes dialog after asset selection', async () => {
const { mockShowDialog, mockCloseDialog } = setupDialogMocks()
const assetBrowserDialog = useAssetBrowserDialog()
const mockAsset = createMockAsset()
await assetBrowserDialog.browse({
assetType: 'models'
})
const dialogCall = mockShowDialog.mock.calls[0][0]
const onSelectHandler = dialogCall.props.onSelect
onSelectHandler(mockAsset)
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'global-asset-browser'
})
})
it('uses custom title when provided', async () => {
const { mockShowDialog } = setupDialogMocks()
const assetBrowserDialog = useAssetBrowserDialog()
await assetBrowserDialog.browse({
assetType: 'models',
title: 'Custom Model Browser'
})
const dialogCall = mockShowDialog.mock.calls[0][0]
expect(dialogCall.props.title).toBe('Custom Model Browser')
})
it('calls getAssetsByTag with correct assetType parameter', async () => {
setupDialogMocks()
const assetBrowserDialog = useAssetBrowserDialog()
await assetBrowserDialog.browse({
assetType: 'models'
})
expect(mockGetAssetsByTag).toHaveBeenCalledWith('models')
})
it('passes fetched assets to dialog props', async () => {
const { mockShowDialog } = setupDialogMocks()
const assetBrowserDialog = useAssetBrowserDialog()
const mockAssets = [
createMockAsset({ id: 'asset-1', name: 'model1.safetensors' }),
createMockAsset({ id: 'asset-2', name: 'model2.safetensors' })
]
mockGetAssetsByTag.mockResolvedValueOnce(mockAssets)
await assetBrowserDialog.browse({
assetType: 'models'
})
const dialogCall = mockShowDialog.mock.calls[0][0]
expect(dialogCall.props.assets).toEqual(mockAssets)
})
it('handles asset fetch errors gracefully', async () => {
const { mockShowDialog } = setupDialogMocks()
const assetBrowserDialog = useAssetBrowserDialog()
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
mockGetAssetsByTag.mockRejectedValueOnce(new Error('Network error'))
await assetBrowserDialog.browse({
assetType: 'models'
})
expect(mockShowDialog).toHaveBeenCalled()
const dialogCall = mockShowDialog.mock.calls[0][0]
expect(dialogCall.props.assets).toEqual([])
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to fetch assets for tag:',
'models',
expect.any(Error)
)
consoleErrorSpy.mockRestore()
})
})
describe('.show() title formatting', () => {
it('formats title with VAE acronym uppercase', async () => {
const { mockShowDialog } = setupDialogMocks()
mockGetAssetsForNodeType.mockResolvedValueOnce([
createMockAsset({ tags: ['models', 'vae'] })
])
const assetBrowserDialog = useAssetBrowserDialog()
await assetBrowserDialog.show({
nodeType: 'VAELoader',
inputName: 'vae_name'
})
const dialogCall = mockShowDialog.mock.calls[0][0]
expect(dialogCall.props.title).toContain('VAE')
})
it('replaces underscores with spaces in tag names', async () => {
const { mockShowDialog } = setupDialogMocks()
mockGetAssetsForNodeType.mockResolvedValueOnce([
createMockAsset({ tags: ['models', 'style_models'] })
])
const assetBrowserDialog = useAssetBrowserDialog()
await assetBrowserDialog.show({
nodeType: 'StyleModelLoader',
inputName: 'style_model_name'
})
const dialogCall = mockShowDialog.mock.calls[0][0]
expect(dialogCall.props.title).toContain('style models')
})
})
})

View File

@@ -18,7 +18,7 @@ describe('useAssetFilterOptions', () => {
createAssetWithSpecificExtension('pt')
]
const { availableFileFormats } = useAssetFilterOptions(assets)
const { availableFileFormats } = useAssetFilterOptions(() => assets)
expect(availableFileFormats.value).toEqual([
{ name: '.ckpt', value: 'ckpt' },
@@ -34,7 +34,7 @@ describe('useAssetFilterOptions', () => {
createAssetWithSpecificExtension('ckpt')
]
const { availableFileFormats } = useAssetFilterOptions(assets)
const { availableFileFormats } = useAssetFilterOptions(() => assets)
expect(availableFileFormats.value).toEqual([
{ name: '.ckpt', value: 'ckpt' },
@@ -48,7 +48,7 @@ describe('useAssetFilterOptions', () => {
createAssetWithSpecificExtension('safetensors')
]
const { availableFileFormats } = useAssetFilterOptions(assets)
const { availableFileFormats } = useAssetFilterOptions(() => assets)
expect(availableFileFormats.value).toEqual([
{ name: '.safetensors', value: 'safetensors' }
@@ -56,7 +56,7 @@ describe('useAssetFilterOptions', () => {
})
it('handles empty asset list', () => {
const { availableFileFormats } = useAssetFilterOptions([])
const { availableFileFormats } = useAssetFilterOptions(() => [])
expect(availableFileFormats.value).toEqual([])
})
@@ -70,7 +70,7 @@ describe('useAssetFilterOptions', () => {
createAssetWithSpecificBaseModel('sd35')
]
const { availableBaseModels } = useAssetFilterOptions(assets)
const { availableBaseModels } = useAssetFilterOptions(() => assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sd15', value: 'sd15' },
@@ -86,7 +86,7 @@ describe('useAssetFilterOptions', () => {
createAssetWithSpecificBaseModel('sdxl')
]
const { availableBaseModels } = useAssetFilterOptions(assets)
const { availableBaseModels } = useAssetFilterOptions(() => assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sd15', value: 'sd15' },
@@ -100,7 +100,7 @@ describe('useAssetFilterOptions', () => {
createAssetWithSpecificBaseModel('sd15')
]
const { availableBaseModels } = useAssetFilterOptions(assets)
const { availableBaseModels } = useAssetFilterOptions(() => assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sd15', value: 'sd15' }
@@ -113,7 +113,7 @@ describe('useAssetFilterOptions', () => {
createAssetWithSpecificBaseModel('sdxl')
]
const { availableBaseModels } = useAssetFilterOptions(assets)
const { availableBaseModels } = useAssetFilterOptions(() => assets)
expect(availableBaseModels.value).toEqual([
{ name: 'sdxl', value: 'sdxl' }
@@ -121,7 +121,7 @@ describe('useAssetFilterOptions', () => {
})
it('handles empty asset list', () => {
const { availableBaseModels } = useAssetFilterOptions([])
const { availableBaseModels } = useAssetFilterOptions(() => [])
expect(availableBaseModels.value).toEqual([])
})
@@ -132,9 +132,8 @@ describe('useAssetFilterOptions', () => {
const assets = [createAssetWithSpecificExtension('safetensors')]
const { availableFileFormats, availableBaseModels } =
useAssetFilterOptions(assets)
useAssetFilterOptions(() => assets)
// These should be computed refs
expect(availableFileFormats.value).toBeDefined()
expect(availableBaseModels.value).toBeDefined()
expect(typeof availableFileFormats.value).toBe('object')