Compare commits

...

1 Commits

Author SHA1 Message Date
huang47
491642e4fb refactor: handle asset settle failures per-promise 2026-06-30 22:40:42 -07:00
2 changed files with 535 additions and 22 deletions

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
@@ -11,6 +12,7 @@ import type {
} from '@/platform/assets/schemas/assetSchema'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { assetService } from '@/platform/assets/services/assetService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
// Mock the api module
vi.mock('@/scripts/api', () => ({
@@ -48,6 +50,11 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
// Node types whose category lookup should throw, simulating a refresh failure
const mockCategoryLookupFailures = vi.hoisted(() => ({
nodeTypes: new Set<string>()
}))
// Mock modelToNodeStore with proper node providers and category lookups
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({
@@ -69,6 +76,9 @@ vi.mock('@/stores/modelToNodeStore', () => ({
return providers[category] ?? []
}),
getCategoryForNodeType: vi.fn((nodeType: string) => {
if (mockCategoryLookupFailures.nodeTypes.has(nodeType)) {
throw new Error(`No category registered for node type: ${nodeType}`)
}
const nodeToCategory: Record<string, string> = {
CheckpointLoaderSimple: 'checkpoints',
ImageOnlyCheckpointLoader: 'checkpoints',
@@ -96,6 +106,10 @@ const mockOutputOverrides = vi.hoisted(() => ({
value: null as MockOutput[] | null
}))
const mockAssetMapperOptions = vi.hoisted(() => ({
omitCreatedAtForIds: new Set<string>()
}))
// Mock TaskItemImpl
const PREVIEWABLE_MEDIA_TYPES = new Set(['images', 'video', 'audio'])
@@ -169,11 +183,14 @@ vi.mock('@/platform/assets/composables/media/assetMappers', () => ({
})),
mapTaskOutputToAssetItem: vi.fn((task, output) => {
const index = parseInt(task.jobId.split('_')[1]) || 0
const createdAt = new Date(Date.now() - index * 1000).toISOString()
return {
id: task.jobId,
name: output.filename,
size: 0,
created_at: new Date(Date.now() - index * 1000).toISOString(),
...(!mockAssetMapperOptions.omitCreatedAtForIds.has(task.jobId) && {
created_at: createdAt
}),
tags: ['output'],
preview_url: output.url,
user_metadata: {}
@@ -205,6 +222,7 @@ describe('assetsStore - Refactored (Option A)', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useAssetsStore()
vi.clearAllMocks()
mockAssetMapperOptions.omitCreatedAtForIds.clear()
})
describe('Initial Load', () => {
@@ -272,6 +290,17 @@ describe('assetsStore - Refactored (Option A)', () => {
'prompt_2'
])
})
it('should skip unfinished jobs and completed jobs without previews', async () => {
vi.mocked(api.getHistory).mockResolvedValue([
{ ...createMockJobItem(0), status: 'in_progress' },
{ ...createMockJobItem(1), preview_output: undefined }
])
await store.updateHistory()
expect(store.historyAssets).toEqual([])
})
})
describe('Pagination', () => {
@@ -328,6 +357,46 @@ describe('assetsStore - Refactored (Option A)', () => {
expect(uniqueAssetIds.size).toBe(store.historyAssets.length)
})
it('should insert newer paginated items in sorted order', async () => {
vi.mocked(api.getHistory).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
)
await store.updateHistory()
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(-1)])
await store.loadMoreHistory()
expect(store.historyAssets[0].id).toBe('prompt_-1')
})
it('sorts paginated items when the incoming asset has no timestamp', async () => {
vi.mocked(api.getHistory).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
)
await store.updateHistory()
mockAssetMapperOptions.omitCreatedAtForIds.add('prompt_200')
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(200)])
await store.loadMoreHistory()
expect(store.historyAssets.at(-1)?.id).toBe('prompt_200')
})
it('sorts paginated items when an existing asset has no timestamp', async () => {
for (let i = 0; i < 200; i++) {
mockAssetMapperOptions.omitCreatedAtForIds.add(`prompt_${i}`)
}
vi.mocked(api.getHistory).mockResolvedValueOnce(
Array.from({ length: 200 }, (_, i) => createMockJobItem(i))
)
await store.updateHistory()
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(-1)])
await store.loadMoreHistory()
expect(store.historyAssets[0].id).toBe('prompt_-1')
})
it('should stop loading when no more items', async () => {
// First batch - less than BATCH_SIZE
const firstBatch = Array.from({ length: 50 }, (_, i) =>
@@ -494,6 +563,29 @@ describe('assetsStore - Refactored (Option A)', () => {
expect(store.historyLoading).toBe(false)
expect(store.historyError).toBe(error)
})
it('should preserve existing history when refresh fails', async () => {
vi.mocked(api.getHistory).mockResolvedValueOnce([createMockJobItem(0)])
await store.updateHistory()
const error = new Error('API error')
vi.mocked(api.getHistory).mockRejectedValueOnce(error)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(1)
expect(store.historyError).toBe(error)
})
it('should keep empty history when loadMore fails before any load', async () => {
const error = new Error('API error')
vi.mocked(api.getHistory).mockRejectedValueOnce(error)
await store.loadMoreHistory()
expect(store.historyAssets).toEqual([])
expect(store.historyError).toBe(error)
})
})
describe('Memory Management', () => {
@@ -778,6 +870,7 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockIsCloud.value = true
mockCategoryLookupFailures.nodeTypes.clear()
vi.clearAllMocks()
})
@@ -924,6 +1017,43 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
vi.mocked(assetService.getAssetsForNodeType)
).toHaveBeenCalledTimes(2)
})
it('ignores a model response after the category is invalidated', async () => {
const store = useAssetsStore()
let resolveFetch!: (assets: AssetItem[]) => void
vi.mocked(assetService.getAssetsForNodeType).mockReturnValueOnce(
new Promise((resolve) => {
resolveFetch = resolve
})
)
const request = store.updateModelsForNodeType('CheckpointLoaderSimple')
store.invalidateCategory('checkpoints')
resolveFetch([createMockAsset('stale-response')])
await request
expect(store.getAssets('CheckpointLoaderSimple')).toEqual([])
})
it('ignores a model rejection after the category is invalidated', async () => {
const store = useAssetsStore()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
let rejectFetch!: (error: Error) => void
vi.mocked(assetService.getAssetsForNodeType).mockReturnValueOnce(
new Promise((_resolve, reject) => {
rejectFetch = reject
})
)
const request = store.updateModelsForNodeType('CheckpointLoaderSimple')
store.invalidateCategory('checkpoints')
rejectFetch(new Error('stale rejection'))
await request
expect(store.getError('CheckpointLoaderSimple')).toBeUndefined()
expect(consoleSpy).not.toHaveBeenCalled()
consoleSpy.mockRestore()
})
})
describe('shallowReactive state reactivity', () => {
@@ -966,6 +1096,10 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
it('should return empty array for unknown node types', () => {
const store = useAssetsStore()
expect(store.getAssets('UnknownNodeType')).toEqual([])
expect(store.isModelLoading('UnknownNodeType')).toBe(false)
expect(store.getError('UnknownNodeType')).toBeUndefined()
expect(store.hasMore('UnknownNodeType')).toBe(false)
expect(store.hasAssetKey('UnknownNodeType')).toBe(false)
})
it('should not fetch for unknown node types', async () => {
@@ -975,6 +1109,63 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
vi.mocked(assetService.getAssetsForNodeType)
).not.toHaveBeenCalled()
})
it('should refresh an already loaded category', async () => {
const store = useAssetsStore()
const nodeType = 'CheckpointLoaderSimple'
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
createMockAsset('first')
])
await store.updateModelsForNodeType(nodeType)
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
createMockAsset('second')
])
await store.updateModelsForNodeType(nodeType)
expect(store.getAssets(nodeType).map((asset) => asset.id)).toEqual([
'second'
])
})
it('reports hasMore for a loaded category', async () => {
const store = useAssetsStore()
const nodeType = 'CheckpointLoaderSimple'
expect(store.hasMore(nodeType)).toBe(false)
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
createMockAsset('only-page')
])
await store.updateModelsForNodeType(nodeType)
expect(store.hasMore(nodeType)).toBe(false)
})
it('should record model loading errors', async () => {
const store = useAssetsStore()
const error = new Error('model fetch failed')
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(assetService.getAssetsForNodeType).mockRejectedValueOnce(error)
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.getError('CheckpointLoaderSimple')).toBe(error)
expect(store.isModelLoading('CheckpointLoaderSimple')).toBe(false)
consoleSpy.mockRestore()
})
it('should wrap non-error model loading failures', async () => {
const store = useAssetsStore()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(assetService.getAssetsForNodeType).mockRejectedValueOnce('boom')
await store.updateModelsForNodeType('CheckpointLoaderSimple')
expect(store.getError('CheckpointLoaderSimple')?.message).toBe('boom')
consoleSpy.mockRestore()
})
})
describe('invalidateCategory', () => {
@@ -1129,7 +1320,177 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
})
})
describe('completed download refresh', () => {
it('refreshes provider and tag caches for the completed model type', async () => {
const store = useAssetsStore()
const downloadStore = useAssetDownloadStore()
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue([])
vi.mocked(assetService.getAssetsByTag).mockResolvedValue([])
downloadStore.lastCompletedDownload = {
taskId: 'task-1',
modelType: 'checkpoints',
timestamp: 1
}
await vi.waitFor(() =>
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
'models',
true,
expect.objectContaining({ limit: 500, offset: 0 })
)
)
expect(assetService.getAssetsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
expect.objectContaining({ limit: 500, offset: 0 })
)
expect(assetService.getAssetsForNodeType).toHaveBeenCalledTimes(1)
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
'checkpoints',
true,
expect.objectContaining({ limit: 500, offset: 0 })
)
expect(store.hasCategory('tag:models')).toBe(true)
})
it('settles the batch when one provider refresh rejects', async () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
mockCategoryLookupFailures.nodeTypes.add('ImageOnlyCheckpointLoader')
useAssetsStore()
const downloadStore = useAssetDownloadStore()
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue([])
vi.mocked(assetService.getAssetsByTag).mockResolvedValue([])
downloadStore.lastCompletedDownload = {
taskId: 'task-2',
modelType: 'checkpoints',
timestamp: 2
}
await vi.waitFor(() =>
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('ImageOnlyCheckpointLoader'),
expect.any(Error)
)
)
expect(assetService.getAssetsForNodeType).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
expect.objectContaining({ limit: 500, offset: 0 })
)
expect(assetService.getAssetsByTag).toHaveBeenCalledWith(
'models',
true,
expect.objectContaining({ limit: 500, offset: 0 })
)
consoleErrorSpy.mockRestore()
})
})
describe('updateAssetMetadata optimistic cache', () => {
it('still writes metadata when a cache key is unresolved', async () => {
const store = useAssetsStore()
const original = {
...createMockAsset('opt-unknown'),
user_metadata: { note: 'before' } as Record<string, unknown>
}
vi.mocked(assetService.updateAsset).mockResolvedValueOnce({
...original,
user_metadata: { note: 'after' }
})
await store.updateAssetMetadata(
original,
{ note: 'after' },
'UnknownNodeType'
)
expect(vi.mocked(assetService.updateAsset)).toHaveBeenCalledWith(
'opt-unknown',
{ user_metadata: { note: 'after' } }
)
})
it('still updates the server when the asset is not cached', async () => {
const store = useAssetsStore()
const original = {
...createMockAsset('opt-missing'),
user_metadata: { note: 'before' } as Record<string, unknown>
}
vi.mocked(assetService.updateAsset).mockResolvedValueOnce({
...original,
user_metadata: { note: 'server' }
})
await store.updateAssetMetadata(original, { note: 'after' })
expect(vi.mocked(assetService.updateAsset)).toHaveBeenCalledWith(
'opt-missing',
{ user_metadata: { note: 'after' } }
)
})
it('still updates the server when a resolved cache key has not loaded yet', async () => {
const store = useAssetsStore()
const original = {
...createMockAsset('opt-unloaded'),
user_metadata: { note: 'before' } as Record<string, unknown>
}
vi.mocked(assetService.updateAsset).mockResolvedValueOnce({
...original,
user_metadata: { note: 'server' }
})
await store.updateAssetMetadata(
original,
{ note: 'after' },
'CheckpointLoaderSimple'
)
expect(vi.mocked(assetService.updateAsset)).toHaveBeenCalledWith(
'opt-unloaded',
{ user_metadata: { note: 'after' } }
)
})
it('leaves unrelated cached assets alone during optimistic metadata update', async () => {
const store = useAssetsStore()
const cached = {
...createMockAsset('opt-cached'),
user_metadata: { note: 'cached' } as Record<string, unknown>
}
const missing = {
...createMockAsset('opt-missing-from-cache'),
user_metadata: { note: 'before' } as Record<string, unknown>
}
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
cached
])
await store.updateModelsForNodeType('CheckpointLoaderSimple')
vi.mocked(assetService.updateAsset).mockResolvedValueOnce({
...missing,
user_metadata: { note: 'server' }
})
await store.updateAssetMetadata(
missing,
{ note: 'after' },
'CheckpointLoaderSimple'
)
expect(
store.getAssets('CheckpointLoaderSimple')[0].user_metadata
).toEqual({
note: 'cached'
})
})
it('reflects the server response in the cache after a successful update', async () => {
const store = useAssetsStore()
const original = {
@@ -1237,6 +1598,31 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
'featured'
])
})
it('calls only the remove endpoint when there are no tags to add', async () => {
const store = useAssetsStore()
const asset = createMockAsset('tags-remove-only', ['models', 'archived'])
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
asset
])
await store.updateModelsForNodeType('CheckpointLoaderSimple')
vi.mocked(assetService.removeAssetTags).mockResolvedValueOnce({
total_tags: ['models']
})
await store.updateAssetTags(asset, ['models'], 'CheckpointLoaderSimple')
expect(vi.mocked(assetService.removeAssetTags)).toHaveBeenCalledWith(
'tags-remove-only',
['archived']
)
expect(vi.mocked(assetService.addAssetTags)).not.toHaveBeenCalled()
expect(store.getAssets('CheckpointLoaderSimple')[0].tags).toEqual([
'models'
])
})
})
describe('updateAssetTags partial-failure compensation', () => {
@@ -1351,6 +1737,36 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => {
expect(store.hasCategory('tag:models')).toBe(false)
})
it('keeps unrelated tag caches when compensation fails with a cache key', async () => {
const store = useAssetsStore()
const asset = createMockAsset('tags-target-fail', ['models', 'loras'])
const otherAsset = createMockAsset('tags-other', ['models'])
vi.mocked(assetService.getAssetsForNodeType).mockResolvedValueOnce([
asset
])
await store.updateModelsForNodeType('LoraLoader')
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([otherAsset])
await store.updateModelsForTag('models')
vi.mocked(assetService.removeAssetTags).mockResolvedValueOnce({
removed: ['loras'],
total_tags: ['models']
})
vi.mocked(assetService.addAssetTags)
.mockRejectedValueOnce(new Error('500 add failed'))
.mockRejectedValueOnce(new Error('503 compensation failed'))
await store.updateAssetTags(
asset,
['models', 'checkpoints'],
'LoraLoader'
)
expect(store.hasCategory('loras')).toBe(false)
expect(store.hasCategory('tag:models')).toBe(true)
})
it('does not attempt compensation when only the add was attempted', async () => {
const store = useAssetsStore()
const asset = createMockAsset('tags-add-only-fail', ['models'])
@@ -1483,9 +1899,78 @@ describe('assetsStore - Deletion State and Input Mapping', () => {
const store = useAssetsStore()
expect(store.getInputName('unknown.png')).toBe('unknown.png')
})
it('ignores input assets without hashes', async () => {
mockIsCloud.value = true
try {
setActivePinia(createTestingPinia({ stubActions: false }))
const store = useAssetsStore()
vi.mocked(assetService.getAssetsByTag).mockResolvedValueOnce([
{
id: 'input-1',
name: 'plain.png',
tags: ['input']
}
])
await store.updateInputs()
expect(store.getInputName('plain.png')).toBe('plain.png')
} finally {
mockIsCloud.value = false
}
})
})
describe('updateInputs cloud routing', () => {
it('reads input files from the internal API when isCloud is false', async () => {
const fetchMock = vi.fn().mockResolvedValue(
fromAny<Response, unknown>({
ok: true,
json: async () => ['input-a.png', 'input-b.png']
})
)
vi.stubGlobal('fetch', fetchMock)
try {
const store = useAssetsStore()
await store.updateInputs()
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:3000/files/input',
{ headers: { 'Comfy-User': 'test-user' } }
)
expect(store.inputAssets.map((asset) => asset.name)).toEqual([
'input-a.png',
'input-b.png'
])
} finally {
vi.unstubAllGlobals()
}
})
it('records internal input API failures', async () => {
const fetchMock = vi.fn().mockResolvedValue(
fromAny<Response, unknown>({
ok: false
})
)
vi.stubGlobal('fetch', fetchMock)
try {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const store = useAssetsStore()
await store.updateInputs()
expect(store.inputError).toBeInstanceOf(Error)
consoleSpy.mockRestore()
} finally {
vi.unstubAllGlobals()
}
})
it('reads from assetService.getAssetsByTag with limit 100 when isCloud is true', async () => {
mockIsCloud.value = true
try {
@@ -1586,6 +2071,18 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
expect(store.flatOutputHasMore).toBe(false)
})
it('does not load more flat outputs when there are no more pages', async () => {
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage([makeAsset('a1', 'one.png')])
)
const store = useAssetsStore()
await store.updateFlatOutputs()
await store.loadMoreFlatOutputs()
expect(assetService.getAssetsPageByTag).toHaveBeenCalledTimes(1)
})
it('threads the minted cursor into after on loadMore and omits offset', async () => {
vi.mocked(assetService.getAssetsPageByTag)
.mockResolvedValueOnce(
@@ -1800,4 +2297,26 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])
})
it('ignores concurrent load more calls while one is active', async () => {
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage([makeAsset('a1', 'f1.png')], { hasMore: true })
)
const store = useAssetsStore()
await store.updateFlatOutputs()
let resolvePage!: (page: AssetResponse) => void
vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce(
new Promise<AssetResponse>((resolve) => {
resolvePage = resolve
})
)
const first = store.loadMoreFlatOutputs()
const second = store.loadMoreFlatOutputs()
resolvePage(makePage([makeAsset('a2', 'f2.png')]))
await Promise.all([first, second])
expect(assetService.getAssetsPageByTag).toHaveBeenCalledTimes(2)
})
})

View File

@@ -837,30 +837,24 @@ export const useAssetsStore = defineStore('assets', () => {
.getAllNodeProviders(modelType)
.filter((provider) => provider.nodeDef?.name)
const nodeTypeUpdates = providers.map((provider) =>
updateModelsForNodeType(provider.nodeDef.name).then(
() => provider.nodeDef.name
)
)
const nodeTypeRefreshes = providers.map((provider) => ({
target: `node type "${provider.nodeDef.name}"`,
refresh: updateModelsForNodeType(provider.nodeDef.name)
}))
// Also update by tag in case modal was opened with assetType
const tagUpdates = [
updateModelsForTag(modelType),
updateModelsForTag('models')
]
const tagRefreshes = [modelType, 'models'].map((tag) => ({
target: `tag "${tag}"`,
refresh: updateModelsForTag(tag)
}))
const results = await Promise.allSettled([
...nodeTypeUpdates,
...tagUpdates
])
for (const result of results) {
if (result.status === 'rejected') {
console.error(
`Failed to refresh model cache for provider: ${result.reason}`
)
}
}
await Promise.all(
[...nodeTypeRefreshes, ...tagRefreshes].map(({ target, refresh }) =>
refresh.catch((error) => {
console.error(`Failed to refresh model cache for ${target}`, error)
})
)
)
}
)