mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-03 05:38:26 +00:00
Compare commits
1 Commits
codex/cove
...
shihchi/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4353c37d05 |
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
300
src/stores/assetExportStore.test.ts
Normal file
300
src/stores/assetExportStore.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user