Compare commits

...

1 Commits

Author SHA1 Message Date
huang47
4353c37d05 test: cover asset, model and registry stores 2026-06-30 22:37:23 -07:00
8 changed files with 1820 additions and 11 deletions

View File

@@ -126,6 +126,19 @@ describe('useAssetDownloadStore', () => {
})
})
it('keeps the first placeholder when the same task is tracked twice', () => {
const store = useAssetDownloadStore()
store.trackDownload('task-123', 'checkpoints', 'first.safetensors')
store.trackDownload('task-123', 'loras', 'second.safetensors')
expect(store.downloadList).toHaveLength(1)
expect(store.downloadList[0]).toMatchObject({
modelType: 'checkpoints',
assetName: 'first.safetensors'
})
})
it('handles out-of-order messages where completed arrives before progress', () => {
const store = useAssetDownloadStore()
@@ -179,6 +192,19 @@ describe('useAssetDownloadStore', () => {
expect(store.finishedDownloads[0].status).toBe('completed')
})
it('skips polling when active downloads have fresh progress', async () => {
const store = useAssetDownloadStore()
dispatch(createDownloadMessage({ status: 'running' }))
await vi.advanceTimersByTimeAsync(9_999)
dispatch(createDownloadMessage({ status: 'running', progress: 75 }))
await vi.advanceTimersByTimeAsync(1)
expect(taskService.getTask).not.toHaveBeenCalled()
expect(store.activeDownloads).toHaveLength(1)
expect(store.activeDownloads[0].progress).toBe(75)
})
it('polls and marks failed downloads', async () => {
const store = useAssetDownloadStore()
@@ -311,5 +337,22 @@ describe('useAssetDownloadStore', () => {
expect(store.sessionDownloadCount).toBe(0)
expect(store.isDownloadedThisSession('asset-456')).toBe(false)
})
it('does not acknowledge unrelated completed downloads', () => {
const store = useAssetDownloadStore()
dispatch(
createDownloadMessage({
status: 'completed',
progress: 100,
asset_id: 'asset-456'
})
)
store.acknowledgeAsset('other-asset')
expect(store.sessionDownloadCount).toBe(1)
expect(store.isDownloadedThisSession('asset-456')).toBe(true)
})
})
})

View File

@@ -0,0 +1,300 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueUse from '@vueuse/core'
import type { AssetExportWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import type { TaskId } from '@/platform/tasks/services/taskService'
import { useAssetExportStore } from '@/stores/assetExportStore'
const { getExportDownloadUrl, getTask, toastAdd, intervalState } = vi.hoisted(
() => ({
getExportDownloadUrl: vi.fn(),
getTask: vi.fn(),
toastAdd: vi.fn(),
intervalState: { cb: null as null | (() => void) }
})
)
vi.mock('@vueuse/core', async (importOriginal) => ({
...(await importOriginal<typeof VueUse>()),
useIntervalFn: (cb: () => void) => {
intervalState.cb = cb
return { pause: vi.fn(), resume: vi.fn() }
}
}))
vi.mock('@/scripts/api', () => ({
api: { addEventListener: vi.fn() }
}))
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: { getExportDownloadUrl }
}))
vi.mock('@/platform/tasks/services/taskService', () => ({
taskService: { getTask }
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: toastAdd })
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
function wsMessage(
over: Partial<AssetExportWsMessage> = {}
): AssetExportWsMessage {
return {
task_id: 'task-1',
export_name: 'export.zip',
assets_total: 10,
assets_attempted: 5,
assets_failed: 0,
bytes_total: 1000,
bytes_processed: 500,
progress: 0.5,
status: 'running',
...over
}
}
const taskId = (id: string) => id as TaskId
/**
* Build a store and an `emit` bound to the real `asset_export` listener the
* store registers on `api`, so tests drive the state machine through its
* actual entry point rather than a private method.
*/
function setup() {
const store = useAssetExportStore()
const entry = vi
.mocked(api.addEventListener)
.mock.calls.find((c) => c[0] === 'asset_export')
const handler = entry![1] as (e: { detail: AssetExportWsMessage }) => void
const emit = (msg: AssetExportWsMessage) => handler({ detail: msg })
// Run the polling tick that `useIntervalFn` would normally fire, and let its
// async work settle.
const runPoll = async () => {
intervalState.cb?.()
await new Promise((resolve) => setTimeout(resolve, 0))
}
return { store, emit, runPoll }
}
const STALE_AGO_MS = 20_000
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(api.addEventListener).mockClear()
getExportDownloadUrl
.mockReset()
.mockResolvedValue({ url: 'https://example.com/export.zip' })
getTask.mockReset()
toastAdd.mockReset()
})
describe('assetExportStore', () => {
it('tracks a new export as created and is idempotent', () => {
const { store } = setup()
store.trackExport(taskId('t1'))
store.trackExport(taskId('t1'))
expect(store.exportList).toHaveLength(1)
expect(store.exportList[0].status).toBe('created')
expect(store.hasExports).toBe(true)
expect(store.hasActiveExports).toBe(true)
})
it('separates active from finished exports by status', () => {
const { store, emit } = setup()
emit(wsMessage({ task_id: 'running', status: 'running' }))
emit(
wsMessage({ task_id: 'failed', status: 'failed', export_name: 'f.zip' })
)
expect(store.activeExports.map((e) => e.taskId)).toEqual(['running'])
expect(store.finishedExports.map((e) => e.taskId)).toEqual(['failed'])
})
it('updates an export from successive websocket messages', () => {
const { store, emit } = setup()
emit(wsMessage({ progress: 0.5, status: 'running' }))
emit(wsMessage({ progress: 0.9, status: 'running' }))
expect(store.exportList).toHaveLength(1)
expect(store.exportList[0].progress).toBe(0.9)
})
it('ignores updates for an export already completed and downloaded', async () => {
const { store, emit } = setup()
emit(wsMessage({ status: 'completed' }))
await Promise.resolve()
const triggeredCalls = getExportDownloadUrl.mock.calls.length
// A late 'running' message must not revive a completed+downloaded export
emit(wsMessage({ status: 'running', progress: 0.1 }))
expect(store.exportList[0].status).toBe('completed')
expect(getExportDownloadUrl).toHaveBeenCalledTimes(triggeredCalls)
})
it('falls back to the prior export name when a message omits it', async () => {
const { store, emit } = setup()
emit(wsMessage({ status: 'running', progress: 0.4 }))
emit(
wsMessage({ status: 'running', export_name: undefined, progress: 0.6 })
)
expect(store.exportList[0].exportName).toBe('export.zip')
})
it('falls back to a blank export name when no message has named it', () => {
const { store, emit } = setup()
emit(wsMessage({ export_name: undefined, status: 'running' }))
expect(store.exportList[0].exportName).toBe('')
})
it('triggers a download for a named export and clears prior errors', async () => {
const { store, emit } = setup()
emit(wsMessage({ status: 'running' }))
const [exp] = store.exportList
await store.triggerDownload(exp)
expect(getExportDownloadUrl).toHaveBeenCalledWith('export.zip')
expect(exp.downloadTriggered).toBe(true)
expect(exp.downloadError).toBeUndefined()
})
it('does not re-trigger a download unless forced', async () => {
const { store, emit } = setup()
emit(wsMessage({ status: 'running' }))
const [exp] = store.exportList
exp.downloadTriggered = true
await store.triggerDownload(exp)
expect(getExportDownloadUrl).not.toHaveBeenCalled()
await store.triggerDownload(exp, true)
expect(getExportDownloadUrl).toHaveBeenCalledTimes(1)
})
it('records a download error and surfaces a toast on failure', async () => {
getExportDownloadUrl.mockRejectedValueOnce(new Error('network down'))
const { store, emit } = setup()
emit(wsMessage({ status: 'running' }))
const [exp] = store.exportList
await store.triggerDownload(exp)
expect(exp.downloadError).toBe('network down')
expect(exp.downloadTriggered).toBe(false)
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
})
it('records a string download error', async () => {
getExportDownloadUrl.mockRejectedValueOnce('offline')
const { store, emit } = setup()
emit(wsMessage({ status: 'running' }))
const [exp] = store.exportList
await store.triggerDownload(exp)
expect(exp.downloadError).toBe('offline')
})
it('clears finished exports while keeping active ones', () => {
const { store, emit } = setup()
emit(wsMessage({ task_id: 'a', status: 'running' }))
emit(wsMessage({ task_id: 'b', status: 'failed', export_name: 'b.zip' }))
store.clearFinishedExports()
expect(store.exportList.map((e) => e.taskId)).toEqual(['a'])
})
it('does not poll when no active export is stale', async () => {
const { emit, runPoll } = setup()
emit(wsMessage({ status: 'running' }))
await runPoll()
expect(getTask).not.toHaveBeenCalled()
})
it('reconciles a stale export from the task service result', async () => {
const { store, emit, runPoll } = setup()
emit(wsMessage({ status: 'running' }))
store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS
getTask.mockResolvedValue({
status: 'completed',
result: { export_name: 'reconciled.zip', assets_total: 10 }
})
await runPoll()
expect(getTask).toHaveBeenCalledWith('task-1')
expect(store.exportList[0].status).toBe('completed')
expect(store.exportList[0].exportName).toBe('reconciled.zip')
})
it('leaves a stale export active when the task is still running', async () => {
const { store, emit, runPoll } = setup()
emit(wsMessage({ status: 'running' }))
store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS
getTask.mockResolvedValue({ status: 'running' })
await runPoll()
expect(store.exportList[0].status).toBe('running')
})
it('reconciles a stale failed export using existing counters', async () => {
const { store, emit, runPoll } = setup()
emit(
wsMessage({
assets_attempted: 4,
assets_failed: 1,
status: 'running'
})
)
store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS
getTask.mockResolvedValue({
status: 'failed',
result: { error: 'failed in result' }
})
await runPoll()
expect(store.exportList[0]).toMatchObject({
assetsAttempted: 4,
assetsFailed: 1,
error: 'failed in result',
status: 'failed'
})
})
it('leaves a stale export untouched when the task lookup fails', async () => {
const { store, emit, runPoll } = setup()
emit(wsMessage({ status: 'running' }))
store.exportList[0].lastUpdate = Date.now() - STALE_AGO_MS
getTask.mockRejectedValue(new Error('task not found'))
await runPoll()
expect(store.exportList[0].status).toBe('running')
})
})

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', () => ({
@@ -96,6 +98,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 +175,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 +214,7 @@ describe('assetsStore - Refactored (Option A)', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useAssetsStore()
vi.clearAllMocks()
mockAssetMapperOptions.omitCreatedAtForIds.clear()
})
describe('Initial Load', () => {
@@ -272,6 +282,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 +349,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 +555,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', () => {
@@ -924,6 +1008,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 +1087,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 +1100,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 +1311,140 @@ 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)
})
})
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 +1552,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 +1691,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 +1853,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 +2025,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 +2251,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

@@ -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 { ref } from 'vue'
@@ -177,9 +178,10 @@ describe('useComfyRegistryStore', () => {
it('should return null when fetching a pack with null ID', async () => {
const store = useComfyRegistryStore()
vi.spyOn(store.getPackById, 'call').mockResolvedValueOnce(null)
const result = await store.getPackById.call(null!)
const result = await store.getPackById.call(
fromAny<Parameters<typeof store.getPackById.call>[0], unknown>(null)
)
expect(result).toBeNull()
expect(mockRegistryService.getPackById).not.toHaveBeenCalled()
@@ -206,6 +208,56 @@ describe('useComfyRegistryStore', () => {
)
})
it('should reuse cached packs by ID', async () => {
const store = useComfyRegistryStore()
await store.getPacksByIds.call(['test-pack-id'])
const result = await store.getPacksByIds.call(['test-pack-id'])
expect(result).toEqual([mockNodePack])
expect(mockRegistryService.listAllPacks).toHaveBeenCalledTimes(1)
})
it('should ignore missing packs by ID', async () => {
mockRegistryService.listAllPacks.mockResolvedValueOnce({
nodes: [fromAny<components['schemas']['Node'], unknown>({ name: 'bad' })],
total: 1,
page: 1,
limit: 10
})
const store = useComfyRegistryStore()
const result = await store.getPacksByIds.call(['unknown-pack-id'])
expect(result).toEqual([])
})
it('should handle empty pack lookup responses', async () => {
mockRegistryService.listAllPacks.mockResolvedValueOnce(null)
const store = useComfyRegistryStore()
const result = await store.getPacksByIds.call(['unknown-pack-id'])
expect(result).toEqual([])
})
it('should filter undefined pack IDs before lookup', async () => {
const store = useComfyRegistryStore()
const result = await store.getPacksByIds.call(
fromAny<components['schemas']['Node']['id'][], unknown>([
'test-pack-id',
undefined
])
)
expect(result).toEqual([mockNodePack])
expect(mockRegistryService.listAllPacks).toHaveBeenCalledWith(
{ node_id: ['test-pack-id'] },
expect.any(Object)
)
})
describe('inferPackFromNodeName', () => {
it('should fetch a pack by comfy node name', async () => {
const store = useComfyRegistryStore()

View File

@@ -137,6 +137,88 @@ describe('useModelStore', () => {
expect(model.resolution).toBe('')
})
it('keeps the default model metadata when the server returns null', async () => {
enableMocks()
vi.mocked(api.viewMetadata).mockResolvedValueOnce(null)
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('sdxl')
expect(model.has_loaded_metadata).toBe(false)
})
it('loads model metadata once', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
await model.load()
expect(api.viewMetadata).toHaveBeenCalledTimes(1)
})
it('keeps the default title when the first metadata key is empty', async () => {
enableMocks()
vi.mocked(api.viewMetadata).mockResolvedValueOnce({
'modelspec.title': '',
display_name: 'Fallback title'
})
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('sdxl')
})
it('returns null for unknown loaded model folders', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
await expect(store.getLoadedModelFolder('missing')).resolves.toBeNull()
})
it('should read metadata from suffixed keys and ignore null values', async () => {
enableMocks()
vi.mocked(api.viewMetadata).mockResolvedValueOnce({
'custom.modelspec.title': 'Namespaced title',
'custom.modelspec.author': null,
'custom.modelspec.tags': null
})
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
const model = folderStore!.models['0/sdxl.safetensors']
await model.load()
expect(model.title).toBe('Namespaced title')
expect(model.author).toBe('')
expect(model.tags).toEqual([''])
})
it('should keep extensions for non-safetensors files', async () => {
enableMocks()
vi.mocked(api.getModels).mockResolvedValueOnce([
{ name: 'notes.txt', pathIndex: 0 }
])
store = useModelStore()
await store.loadModelFolders()
const folderStore = await store.getLoadedModelFolder('checkpoints')
expect(folderStore!.models['0/notes.txt'].title).toBe('notes.txt')
})
it('should cache model information', async () => {
enableMocks()
store = useModelStore()
@@ -209,6 +291,23 @@ describe('useModelStore', () => {
expect(api.getModelFolders).toHaveBeenCalledTimes(2)
expect(api.getModels).not.toHaveBeenCalled()
})
it('does not reload previously loaded folders that disappear', async () => {
enableMocks()
store = useModelStore()
await store.loadModelFolders()
await store.getLoadedModelFolder('checkpoints')
vi.mocked(api.getModelFolders).mockResolvedValueOnce([
{ name: 'vae', folders: ['/path/to/vae'] }
])
await store.refresh()
expect(store.modelFolders.map((folder) => folder.directory)).toEqual([
'vae'
])
expect(api.getModels).toHaveBeenCalledTimes(1)
})
})
describe('API switching functionality', () => {

View File

@@ -138,6 +138,22 @@ describe('useModelToNodeStore', () => {
expect(provider?.key).toBe('ckpt_name')
})
it('omits providers whose node definition is unavailable from reverse lookup', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.modelToNodeMap = {
missing: [
new ModelNodeProvider(
undefined as unknown as ComfyNodeDefImpl,
'model'
)
]
}
expect(modelToNodeStore.getRegisteredNodeTypes()).not.toHaveProperty(
'undefined'
)
})
it('should return undefined for unregistered model type', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()
@@ -577,6 +593,22 @@ describe('useModelToNodeStore', () => {
expect(modelToNodeStore.getCategoryForNodeType('')).toBeUndefined()
})
it('skips providers without node definitions during category lookup', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.modelToNodeMap = {
missing: [
new ModelNodeProvider(
undefined as unknown as ComfyNodeDefImpl,
'model'
)
]
}
expect(
modelToNodeStore.getCategoryForNodeType('MissingNode')
).toBeUndefined()
})
it('maps the IC-LoRA Loader Model Only node to loras so its lora_name dropdown uses the cloud asset browser (FE-838)', () => {
const modelToNodeStore = useModelToNodeStore()
modelToNodeStore.registerDefaults()

View File

@@ -1,16 +1,25 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import axios from 'axios'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toRaw } from 'vue'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
ComfyNodeDefImpl,
buildNodeDefTree,
createDummyFolderNodeDef,
useNodeDefStore,
useNodeFrequencyStore
} from '@/stores/nodeDefStore'
import type { NodeDefFilter } from '@/stores/nodeDefStore'
describe('useNodeDefStore', () => {
@@ -21,6 +30,10 @@ describe('useNodeDefStore', () => {
store = useNodeDefStore()
})
afterEach(() => {
vi.restoreAllMocks()
})
const createMockNodeDef = (
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef => ({
@@ -39,7 +52,112 @@ describe('useNodeDefStore', () => {
...overrides
})
describe('ComfyNodeDefImpl', () => {
it('migrates defaultInput options and applies constructor fallbacks', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const nodeDef = createMockNodeDef({
category: '_for_testing/coverage',
deprecated: undefined,
dev_only: undefined,
experimental: undefined,
help: undefined,
input: {
required: { prompt: ['STRING', { defaultInput: true }] },
optional: { seed_override: ['INT', { defaultInput: true }] }
}
})
const impl = new ComfyNodeDefImpl(nodeDef)
expect(warn).toHaveBeenCalledTimes(2)
expect(impl.help).toBe('')
expect(impl.experimental).toBe(true)
expect(impl.dev_only).toBe(false)
expect(impl.inputs.seed_override.forceInput).toBe(true)
})
it('derives empty-category node paths and lifecycle badges', () => {
const deprecated = new ComfyNodeDefImpl(
createMockNodeDef({ category: '', deprecated: undefined })
)
const beta = new ComfyNodeDefImpl(
createMockNodeDef({ experimental: true })
)
const dev = new ComfyNodeDefImpl(createMockNodeDef({ dev_only: true }))
const normal = new ComfyNodeDefImpl(createMockNodeDef())
expect(deprecated.nodePath).toBe('TestNode')
expect(deprecated.isDummyFolder).toBe(false)
expect(deprecated.nodeLifeCycleBadgeText).toBe('[DEPR]')
expect(beta.nodeLifeCycleBadgeText).toBe('[BETA]')
expect(dev.nodeLifeCycleBadgeText).toBe('[DEV]')
expect(normal.nodeLifeCycleBadgeText).toBe('')
})
it('defaults missing legacy input and output fields', () => {
const nodeDef = new ComfyNodeDefImpl(
fromAny<ComfyNodeDef, unknown>({
name: 'FallbackNode',
display_name: 'Fallback Node',
category: 'test',
python_module: 'test_module',
description: 'Test node',
output_node: false
})
)
expect(nodeDef.input).toEqual({})
expect(nodeDef.output).toEqual([])
})
it('post-processes search scores with node frequency', async () => {
vi.spyOn(axios, 'get').mockResolvedValue({ data: { TestNode: 7 } })
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
const nodeDef = new ComfyNodeDefImpl(createMockNodeDef())
expect(nodeDef.postProcessSearchScores([10, 4, 2])).toEqual([
10, -7, 4, 2
])
})
})
describe('tree helpers', () => {
it('builds node definition trees from default and custom paths', () => {
const nodeDef = new ComfyNodeDefImpl(
createMockNodeDef({ name: 'TreeNode', category: 'root/branch' })
)
expect(buildNodeDefTree([nodeDef]).children?.[0].label).toBe('root')
expect(
buildNodeDefTree([nodeDef], {
pathExtractor: (node) => ['custom', node.name]
}).children?.[0].label
).toBe('custom')
})
it('normalizes dummy folder paths', () => {
expect(createDummyFolderNodeDef('folder/').category).toBe('folder')
expect(createDummyFolderNodeDef('folder').category).toBe('folder')
})
})
describe('filter registry', () => {
it('updates LiteGraph skip state for registered dev-only nodes', () => {
const registeredNodeTypes = LiteGraph.registered_node_types
LiteGraph.registered_node_types = fromAny({
DevNode: { nodeData: { dev_only: true }, skip_list: false },
NormalNode: { nodeData: {}, skip_list: false }
})
setActivePinia(createTestingPinia({ stubActions: false }))
useNodeDefStore()
expect(LiteGraph.registered_node_types.DevNode.skip_list).toBe(true)
expect(LiteGraph.registered_node_types.NormalNode.skip_list).toBe(false)
LiteGraph.registered_node_types = registeredNodeTypes
})
it('should register a new filter', () => {
const filter: NodeDefFilter = {
id: 'test.filter',
@@ -287,6 +405,26 @@ describe('useNodeDefStore', () => {
})
describe('allNodeDefsByName', () => {
it('keeps existing ComfyNodeDefImpl instances during updates', () => {
const nodeDef = new ComfyNodeDefImpl(
createMockNodeDef({ name: 'ExistingImpl' })
)
store.updateNodeDefs([nodeDef])
expect(toRaw(store.nodeDefsByName.ExistingImpl)).toBe(nodeDef)
expect(toRaw(store.nodeDefsByDisplayName['Test Node'])).toBe(nodeDef)
})
it('adds one node definition to the name and display-name indexes', () => {
store.addNodeDef(
createMockNodeDef({ name: 'AddedNode', display_name: 'Added Node' })
)
expect(store.nodeDefsByName.AddedNode.name).toBe('AddedNode')
expect(store.nodeDefsByDisplayName['Added Node'].name).toBe('AddedNode')
})
it('should include all node defs by name', () => {
const node1 = createMockNodeDef({ name: 'Node1' })
const node2 = createMockNodeDef({ name: 'Node2' })
@@ -336,6 +474,39 @@ describe('useNodeDefStore', () => {
expect(store.allNodeDefsByName).toHaveProperty('Normal')
expect(store.allNodeDefsByName).toHaveProperty('Deprecated')
})
it('derives unique input and output data types', () => {
store.updateNodeDefs([
createMockNodeDef({
input: {
required: { image: ['IMAGE', {}] },
optional: { mask: ['MASK', {}] }
},
output: ['IMAGE', 'LATENT'],
output_is_list: [false, false],
output_name: ['image', 'latent']
})
])
expect([...store.nodeDataTypes].sort()).toEqual([
'IMAGE',
'LATENT',
'MASK'
])
})
it('looks up node definitions from graph nodes and returns null for misses', () => {
store.updateNodeDefs([createMockNodeDef({ name: 'KnownNode' })])
expect(
store.fromLGraphNode(new LGraphNode('KnownNode', 'KnownNode'))?.name
).toBe('KnownNode')
expect(store.fromLGraphNode(new LGraphNode('', ''))).toBeNull()
expect(
store.getInputSpecForWidget(new LGraphNode('Missing', 'Missing'), 'x')
).toBeUndefined()
expect(store.nodeSearchService).toBeDefined()
})
})
describe('subgraph widget input specs', () => {
@@ -389,6 +560,94 @@ describe('useNodeDefStore', () => {
expect(spec?.type).toBe('STRING')
expect(spec?.default).toBeUndefined()
})
it('returns undefined for missing promoted subgraph inputs', () => {
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
expect(store.getInputSpecForWidget(host, 'missing')).toBeUndefined()
})
it('returns undefined when a subgraph input is not promoted', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
host.addInput('raw', 'STRING')
expect(store.getInputSpecForWidget(host, 'raw')).toBeUndefined()
})
it('returns undefined when a promoted source no longer resolves', () => {
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
host.subgraph.nodes[0].widgets = []
expect(store.getInputSpecForWidget(host, 'prompt')).toBeUndefined()
})
it('returns undefined when concrete promoted widget resolution fails', async () => {
const resolver =
await import('@/core/graph/subgraph/resolveConcretePromotedWidget')
vi.spyOn(resolver, 'resolveConcretePromotedWidget').mockReturnValue(
fromAny({ status: 'failure', failure: 'missing-widget' })
)
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
expect(store.getInputSpecForWidget(host, 'prompt')).toBeUndefined()
})
})
describe('node frequency store', () => {
it('loads frequencies once and exposes top matching node definitions', async () => {
const get = vi.spyOn(axios, 'get').mockResolvedValue({
data: { RankedNode: 10, MissingNode: 3 }
})
store.updateNodeDefs([createMockNodeDef({ name: 'RankedNode' })])
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
await frequencyStore.loadNodeFrequencies()
expect(get).toHaveBeenCalledTimes(1)
expect(frequencyStore.isLoaded).toBe(true)
expect(frequencyStore.getNodeFrequencyByName('RankedNode')).toBe(10)
expect(
frequencyStore.getNodeFrequency(
new ComfyNodeDefImpl(createMockNodeDef({ name: 'RankedNode' }))
)
).toBe(10)
expect(frequencyStore.getNodeFrequencyByName('Unknown')).toBe(0)
expect(frequencyStore.topNodeDefs.map((nodeDef) => nodeDef.name)).toEqual(
['RankedNode']
)
})
it('leaves frequency state unloaded when loading fails', async () => {
const error = new Error('boom')
vi.spyOn(axios, 'get').mockRejectedValue(error)
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const frequencyStore = useNodeFrequencyStore()
await frequencyStore.loadNodeFrequencies()
expect(frequencyStore.isLoaded).toBe(false)
expect(errorSpy).toHaveBeenCalledWith(
'Error loading node frequencies:',
error
)
})
})
describe('performance', () => {

View File

@@ -3,15 +3,41 @@ import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import * as litegraphUtil from '@/utils/litegraphUtil'
const {
mockApiURL,
mockExecutionIdToNodeLocatorId,
mockNodeIdToNodeLocatorId,
mockNodeToNodeLocatorId,
mockReleaseSharedObjectUrl,
mockRetainSharedObjectUrl
} = vi.hoisted(() => ({
mockApiURL: vi.fn((path: string) => `api${path}`),
mockExecutionIdToNodeLocatorId: vi.fn(
(_rootGraph: unknown, id: NodeExecutionId) => id as unknown as NodeLocatorId
),
mockNodeIdToNodeLocatorId: vi.fn(
(id: string | number) => String(id) as NodeLocatorId
),
mockNodeToNodeLocatorId: vi.fn(
(node: { id: string | number }) => String(node.id) as NodeLocatorId
),
mockReleaseSharedObjectUrl: vi.fn(),
mockRetainSharedObjectUrl: vi.fn()
}))
const mockResolveNode = vi.fn()
vi.mock('@/utils/litegraphUtil', () => ({
@@ -20,11 +46,25 @@ vi.mock('@/utils/litegraphUtil', () => ({
resolveNode: (...args: unknown[]) => mockResolveNode(...args)
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (...args: Parameters<typeof mockApiURL>) => mockApiURL(...args)
}
}))
vi.mock('@/utils/objectUrlUtil', () => ({
releaseSharedObjectUrl: (...args: [string | undefined]) =>
mockReleaseSharedObjectUrl(...args),
retainSharedObjectUrl: (...args: [string | undefined]) =>
mockRetainSharedObjectUrl(...args)
}))
const mockGetNodeById = vi.fn()
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: vi.fn(() => '&format=test_webp'),
getRandParam: vi.fn(() => '&rand=1'),
rootGraph: {
getNodeById: (...args: unknown[]) => mockGetNodeById(...args)
},
@@ -49,13 +89,31 @@ const createMockOutputs = (
): ExecutedWsMessage['output'] => ({ images })
vi.mock('@/utils/graphTraversalUtil', () => ({
executionIdToNodeLocatorId: vi.fn((_rootGraph: unknown, id: string) => id)
executionIdToNodeLocatorId: (
...args: Parameters<typeof mockExecutionIdToNodeLocatorId>
) => mockExecutionIdToNodeLocatorId(...args)
}))
beforeEach(() => {
mockExecutionIdToNodeLocatorId.mockImplementation(
(_rootGraph: unknown, id: NodeExecutionId) => id as unknown as NodeLocatorId
)
mockNodeIdToNodeLocatorId.mockImplementation(
(id: string | number) => String(id) as NodeLocatorId
)
mockNodeToNodeLocatorId.mockImplementation(
(node: { id: string | number }) => String(node.id) as NodeLocatorId
)
})
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
nodeIdToNodeLocatorId: vi.fn((id: string | number) => String(id)),
nodeToNodeLocatorId: vi.fn((node: { id: number }) => String(node.id))
nodeIdToNodeLocatorId: (
...args: Parameters<typeof mockNodeIdToNodeLocatorId>
) => mockNodeIdToNodeLocatorId(...args),
nodeToNodeLocatorId: (
...args: Parameters<typeof mockNodeToNodeLocatorId>
) => mockNodeToNodeLocatorId(...args)
}))
}))
@@ -780,6 +838,19 @@ describe('nodeOutputStore setNodeOutputs (widget path)', () => {
expect(store.nodeOutputs['5']?.images?.[0]?.type).toBe('input')
})
it('ignores widget outputs when no locator can be resolved', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
mockNodeToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.setNodeOutputs(node, 'test.png')
expect(store.nodeOutputs).toEqual({})
expect(app.nodeOutputs).toEqual({})
})
it('should skip empty array of filenames after createOutputs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
@@ -789,6 +860,470 @@ describe('nodeOutputStore setNodeOutputs (widget path)', () => {
expect(store.nodeOutputs['5']).toBeUndefined()
expect(app.nodeOutputs['5']).toBeUndefined()
})
it('stores direct result items without wrapping them as image outputs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, { filename: 'direct.png', type: 'temp' })
expect(store.nodeOutputs['5']).toEqual({
filename: 'direct.png',
type: 'temp'
})
})
it('marks animated webp and png filenames when requested', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodeOutputs(node, ['clip.webp', 'still.jpg', 'mask.png'], {
folder: 'output',
isAnimated: true
})
expect(store.nodeOutputs['5']?.animated).toEqual([true, false, true])
expect(store.nodeOutputs['5']?.images?.map((image) => image.type)).toEqual([
'output',
'output',
'output'
])
})
})
describe('nodeOutputStore image URLs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(false)
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(false)
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('returns stored preview URLs before output URLs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
store.setNodePreviewsByLocatorId(createNodeLocatorId(null, toNodeId(5)), [
'blob:preview'
])
expect(store.getNodeImageUrls(node)).toEqual(['blob:preview'])
expect(mockApiURL).not.toHaveBeenCalled()
})
it('builds view URLs from output images', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
app.nodeOutputs['5'] = createMockOutputs(
fromAny([{ filename: 'a.png', subfolder: 'x', type: 'temp' }, null])
)
expect(store.getNodeImageUrls(node)).toEqual([
'api/view?filename=a.png&subfolder=x&type=temp&format=test_webp&rand=1'
])
})
it('returns undefined when a node has neither previews nor outputs', () => {
const store = useNodeOutputStore()
expect(store.getNodeImageUrls(createMockNode({ id: 5 }))).toBeUndefined()
})
it('returns execution previews before execution output URLs', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodePreviewsByExecutionId(executionId, ['blob:preview'])
expect(store.getNodeImageUrlsByExecutionId(executionId, node)).toEqual([
'blob:preview'
])
expect(store.latestPreview).toEqual(['blob:preview'])
expect(mockApiURL).not.toHaveBeenCalled()
})
it('falls back to execution output URLs when no preview exists', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png', type: 'temp' }])
)
expect(store.getNodeImageUrlsByExecutionId(executionId, node)).toEqual([
'api/view?filename=result.png&type=temp&format=test_webp&rand=1'
])
})
})
describe('nodeOutputStore locator misses', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('keeps execution operations inert when no locator can be resolved', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
mockExecutionIdToNodeLocatorId.mockReturnValue(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png' }])
)
store.setNodePreviewsByExecutionId(executionId, ['blob:preview'])
store.revokePreviewsByExecutionId(executionId)
expect(store.getNodeOutputByExecutionId(executionId)).toBeUndefined()
expect(store.getNodePreviewImagesByExecutionId(executionId)).toBeUndefined()
expect(store.nodeOutputs).toEqual({})
expect(store.nodePreviewImages).toEqual({})
})
})
describe('nodeOutputStore merge branches', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('sets outputs when merge is requested without existing output', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
const output = createMockOutputs([{ filename: 'first.png' }])
store.setNodeOutputsByExecutionId(executionId, output, { merge: true })
expect(store.nodeOutputs[executionId]).toEqual(output)
})
it('ignores null outputs', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
fromAny<ExecutedWsMessage['output'], unknown>(null)
)
expect(store.nodeOutputs[executionId]).toBeUndefined()
})
it('overwrites non-array fields during merge', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
const firstOutput: ExecutedWsMessage['output'] = {
images: [{ filename: 'first.png' }],
text: 'old'
}
store.setNodeOutputsByExecutionId(executionId, firstOutput)
store.setNodeOutputsByExecutionId(
executionId,
{ text: ['new'] },
{ merge: true }
)
expect(store.nodeOutputs[executionId]?.images).toEqual([
{ filename: 'first.png' }
])
expect(store.nodeOutputs[executionId]?.text).toEqual(['new'])
})
})
describe('nodeOutputStore previews and removal', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('releases old previews and retains new previews on replacement', () => {
const store = useNodeOutputStore()
const locatorId = createNodeLocatorId(null, toNodeId(5))
store.setNodePreviewsByLocatorId(locatorId, ['blob:first'])
store.setNodePreviewsByLocatorId(locatorId, ['blob:second'])
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:first')
expect(mockRetainSharedObjectUrl).toHaveBeenCalledWith('blob:second')
expect(store.nodePreviewImages[locatorId]).toEqual(['blob:second'])
})
it('starts with an empty preview map when legacy previews are missing', () => {
app.nodePreviewImages = fromAny(undefined)
const store = useNodeOutputStore()
expect(store.nodePreviewImages).toEqual({})
})
it('cancels scheduled revocation when a newer preview arrives', async () => {
vi.useFakeTimers()
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodePreviewsByExecutionId(executionId, ['blob:first'])
store.revokePreviewsByExecutionId(executionId)
store.setNodePreviewsByExecutionId(executionId, ['blob:second'])
await vi.advanceTimersByTimeAsync(400)
vi.useRealTimers()
expect(store.nodePreviewImages[executionId]).toEqual(['blob:second'])
expect(mockReleaseSharedObjectUrl).not.toHaveBeenCalledWith('blob:second')
})
it('revokes locator previews and clears preview state', () => {
const store = useNodeOutputStore()
const locatorId = createNodeLocatorId(null, toNodeId(5))
store.setNodePreviewsByLocatorId(locatorId, ['blob:first'])
store.revokePreviewsByLocatorId(locatorId)
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:first')
expect(store.nodePreviewImages[locatorId]).toBeUndefined()
expect(app.nodePreviewImages[locatorId]).toBeUndefined()
})
it('leaves state unchanged when revoking a locator with no previews', () => {
const store = useNodeOutputStore()
store.revokePreviewsByLocatorId(createNodeLocatorId(null, toNodeId(5)))
expect(mockReleaseSharedObjectUrl).not.toHaveBeenCalled()
expect(store.nodePreviewImages).toEqual({})
})
it('skips non-iterable preview entries when revoking all previews', () => {
const store = useNodeOutputStore()
app.nodePreviewImages = fromAny({
'5': {},
'6': ['blob:preview']
})
store.revokeAllPreviews()
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledTimes(1)
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:preview')
expect(store.nodePreviewImages).toEqual({})
})
it('revokes subgraph previews for the parent node and child nodes', () => {
const store = useNodeOutputStore()
const subgraphId = '11111111-1111-1111-1111-111111111111'
const parentLocatorId = createNodeLocatorId(null, toNodeId(9))
const childLocatorId = createNodeLocatorId(subgraphId, toNodeId(10))
const subgraphNode = fromAny<SubgraphNode, unknown>({
id: toNodeId(9),
graph: { isRootGraph: true },
subgraph: {
id: subgraphId,
nodes: [createMockNode({ id: 10 })]
}
})
store.setNodePreviewsByLocatorId(parentLocatorId, ['blob:parent'])
store.setNodePreviewsByLocatorId(childLocatorId, ['blob:child'])
store.revokeSubgraphPreviews(subgraphNode)
expect(store.nodePreviewImages[parentLocatorId]).toBeUndefined()
expect(store.nodePreviewImages[childLocatorId]).toBeUndefined()
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:parent')
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:child')
})
it('uses the parent graph id for non-root subgraph preview revocation', () => {
const store = useNodeOutputStore()
const graphId = '22222222-2222-2222-2222-222222222222'
const subgraphId = '33333333-3333-3333-3333-333333333333'
const parentLocatorId = createNodeLocatorId(graphId, toNodeId(9))
const subgraphNode = fromAny<SubgraphNode, unknown>({
id: toNodeId(9),
graph: { id: graphId, isRootGraph: false },
subgraph: { id: subgraphId, nodes: [] }
})
store.setNodePreviewsByLocatorId(parentLocatorId, ['blob:parent'])
store.revokeSubgraphPreviews(subgraphNode)
expect(store.nodePreviewImages[parentLocatorId]).toBeUndefined()
})
it('leaves previews alone when a subgraph node has no parent graph', () => {
const store = useNodeOutputStore()
const locatorId = createNodeLocatorId(null, toNodeId(9))
const subgraphNode = fromAny<SubgraphNode, unknown>({
graph: undefined,
subgraph: { nodes: [] }
})
store.setNodePreviewsByLocatorId(locatorId, ['blob:parent'])
store.revokeSubgraphPreviews(subgraphNode)
expect(store.nodePreviewImages[locatorId]).toEqual(['blob:parent'])
})
it('removes outputs and previews for a node id', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png' }])
)
store.setNodePreviewsByExecutionId(executionId, ['blob:preview'])
expect(store.removeNodeOutputs(toNodeId(5))).toBe(true)
expect(store.nodeOutputs[executionId]).toBeUndefined()
expect(store.nodePreviewImages[executionId]).toBeUndefined()
expect(mockReleaseSharedObjectUrl).toHaveBeenCalledWith('blob:preview')
})
it('returns false when removing outputs for a node with no outputs', () => {
const store = useNodeOutputStore()
expect(store.removeNodeOutputsForNode(createMockNode({ id: 9 }))).toBe(
false
)
})
it('returns false when a node id cannot resolve to a locator', () => {
const store = useNodeOutputStore()
mockNodeIdToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
expect(store.removeNodeOutputs(toNodeId(9))).toBe(false)
})
it('removes preview state even when preview entries are not iterable', () => {
const store = useNodeOutputStore()
const executionId = createNodeExecutionId([toNodeId(5)])
store.setNodeOutputsByExecutionId(
executionId,
createMockOutputs([{ filename: 'result.png' }])
)
app.nodePreviewImages[executionId] = fromAny({})
store.nodePreviewImages[executionId] = fromAny({})
expect(store.removeNodeOutputs(toNodeId(5))).toBe(true)
expect(store.nodePreviewImages[executionId]).toBeUndefined()
expect(mockReleaseSharedObjectUrl).not.toHaveBeenCalled()
})
})
describe('nodeOutputStore output refresh', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
app.nodeOutputs = {}
app.nodePreviewImages = {}
})
it('updates stored output images from legacy node images', () => {
const store = useNodeOutputStore()
const node = createMockNode({
id: 5,
images: [{ filename: 'new.png', type: 'temp' }]
})
store.setNodeOutputsByExecutionId(
createNodeExecutionId([toNodeId(5)]),
createMockOutputs([{ filename: 'old.png', type: 'temp' }])
)
store.updateNodeImages(node)
expect(store.nodeOutputs['5']?.images).toEqual([
{ filename: 'new.png', type: 'temp' }
])
})
it('ignores legacy image updates when the node has no images', () => {
const store = useNodeOutputStore()
store.updateNodeImages(createMockNode({ id: 5 }))
expect(store.nodeOutputs).toEqual({})
})
it('ignores legacy image updates when no locator exists', () => {
const store = useNodeOutputStore()
mockNodeIdToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.updateNodeImages(
createMockNode({ id: 5, images: [{ filename: 'new.png' }] })
)
expect(store.nodeOutputs).toEqual({})
})
it('ignores legacy image updates when no output exists', () => {
const store = useNodeOutputStore()
store.updateNodeImages(
createMockNode({ id: 5, images: [{ filename: 'new.png' }] })
)
expect(store.nodeOutputs).toEqual({})
})
it('copies app outputs into reactive state during refresh', () => {
const store = useNodeOutputStore()
const node = createMockNode({ id: 5 })
const output = createMockOutputs([{ filename: 'result.png' }])
app.nodeOutputs['5'] = output
store.refreshNodeOutputs(node)
expect(store.nodeOutputs['5']).toEqual(output)
expect(store.nodeOutputs['5']).not.toBe(output)
})
it('does not refresh when a node has no locator', () => {
const store = useNodeOutputStore()
mockNodeToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.refreshNodeOutputs(createMockNode({ id: 5 }))
expect(store.nodeOutputs).toEqual({})
})
it('does not refresh when app has no output for the node', () => {
const store = useNodeOutputStore()
store.refreshNodeOutputs(createMockNode({ id: 5 }))
expect(store.nodeOutputs).toEqual({})
})
it('keeps unresolved restore output ids as their original ids', () => {
const store = useNodeOutputStore()
const output = createMockOutputs([{ filename: 'saved.png' }])
mockExecutionIdToNodeLocatorId.mockReturnValueOnce(
fromAny<NodeLocatorId, undefined>(undefined)
)
store.restoreOutputs({ missing: output })
expect(store.nodeOutputs.missing).toEqual(output)
})
})
describe('nodeOutputStore syncLegacyNodeImgs', () => {
@@ -894,4 +1429,20 @@ describe('nodeOutputStore syncLegacyNodeImgs', () => {
expect(mockNode.imgs).toEqual([mockImg])
expect(mockNode.imageIndex).toBe(0)
})
it('copies output images onto the legacy node', () => {
LiteGraph.vueNodesMode = true
const store = useNodeOutputStore()
const mockNode = createMockNode({ id: 1 })
const mockImg = document.createElement('img')
mockResolveNode.mockReturnValue(mockNode)
store.setNodeOutputsByExecutionId(
createNodeExecutionId([toNodeId(1)]),
createMockOutputs([{ filename: 'result.png', type: 'temp' }])
)
store.syncLegacyNodeImgs(toNodeId(1), mockImg)
expect(mockNode.images).toEqual([{ filename: 'result.png', type: 'temp' }])
})
})