mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-12 16:40:05 +00:00
## Summary Fix input asset previews (images/videos) disappearing from LoadImage/LoadVideo nodes after execution and a browser tab switch. ## Changes - **What**: Guard `setOutputsByLocatorId` in `imagePreviewStore` to preserve existing input-type preview images (`type: 'input'`) when the incoming execution output has no images. Execution outputs with actual images still overwrite as expected. ## Review Focus - The guard only applies when existing output is an input preview (`type === 'input'` for all images) AND incoming output has no images — this is the exact scenario where execution clobbers upload widget previews. - Root cause: execution results from the backend overwrite the upload widget's synthetic preview for LoadImage/LoadVideo nodes (which produce no output images). Combined with the deferred resize-observer re-observation from PR #8805, returning to a hidden tab reads the now-empty store entry.
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { setActivePinia } from 'pinia'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import type { LGraphNode } 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 * as litegraphUtil from '@/utils/litegraphUtil'
|
|
|
|
vi.mock('@/utils/litegraphUtil', () => ({
|
|
isAnimatedOutput: vi.fn(),
|
|
isVideoNode: vi.fn()
|
|
}))
|
|
|
|
const mockGetNodeById = vi.fn()
|
|
|
|
vi.mock('@/scripts/app', () => ({
|
|
app: {
|
|
getPreviewFormatParam: vi.fn(() => '&format=test_webp'),
|
|
rootGraph: {
|
|
getNodeById: (...args: unknown[]) => mockGetNodeById(...args)
|
|
},
|
|
nodeOutputs: {} as Record<string, unknown>,
|
|
nodePreviewImages: {} as Record<string, string[]>
|
|
}
|
|
}))
|
|
|
|
const createMockNode = (overrides: Record<string, unknown> = {}): LGraphNode =>
|
|
({
|
|
id: 1,
|
|
type: 'TestNode',
|
|
...overrides
|
|
}) as Partial<LGraphNode> as LGraphNode
|
|
|
|
const createMockOutputs = (
|
|
images?: ExecutedWsMessage['output']['images']
|
|
): ExecutedWsMessage['output'] => ({ images })
|
|
|
|
vi.mock('@/utils/graphTraversalUtil', () => ({
|
|
executionIdToNodeLocatorId: vi.fn((_rootGraph: unknown, id: string) => id)
|
|
}))
|
|
|
|
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))
|
|
}))
|
|
}))
|
|
|
|
describe('nodeOutputStore setNodeOutputsByExecutionId with merge', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.clearAllMocks()
|
|
app.nodeOutputs = {}
|
|
app.nodePreviewImages = {}
|
|
})
|
|
|
|
it('should update reactive nodeOutputs.value when merging outputs', () => {
|
|
const store = useNodeOutputStore()
|
|
const executionId = '1'
|
|
|
|
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
|
|
store.setNodeOutputsByExecutionId(executionId, initialOutput)
|
|
|
|
expect(app.nodeOutputs[executionId]?.images).toHaveLength(1)
|
|
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
|
|
|
const newOutput = createMockOutputs([{ filename: 'b.png' }])
|
|
store.setNodeOutputsByExecutionId(executionId, newOutput, { merge: true })
|
|
|
|
expect(app.nodeOutputs[executionId]?.images).toHaveLength(2)
|
|
expect(store.nodeOutputs[executionId]?.images).toHaveLength(2)
|
|
})
|
|
|
|
it('should assign to reactive ref after merge for Vue reactivity', () => {
|
|
const store = useNodeOutputStore()
|
|
const executionId = '1'
|
|
|
|
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
|
|
store.setNodeOutputsByExecutionId(executionId, initialOutput)
|
|
|
|
const newOutput = createMockOutputs([{ filename: 'b.png' }])
|
|
|
|
store.setNodeOutputsByExecutionId(executionId, newOutput, { merge: true })
|
|
|
|
expect(store.nodeOutputs[executionId]).toStrictEqual(
|
|
app.nodeOutputs[executionId]
|
|
)
|
|
expect(store.nodeOutputs[executionId]?.images).toHaveLength(2)
|
|
})
|
|
|
|
it('should create a new object reference on merge so Vue detects the change', () => {
|
|
const store = useNodeOutputStore()
|
|
const executionId = '1'
|
|
|
|
const initialOutput = createMockOutputs([{ filename: 'a.png' }])
|
|
store.setNodeOutputsByExecutionId(executionId, initialOutput)
|
|
|
|
const refBefore = store.nodeOutputs[executionId]
|
|
|
|
const newOutput = createMockOutputs([{ filename: 'b.png' }])
|
|
store.setNodeOutputsByExecutionId(executionId, newOutput, { merge: true })
|
|
|
|
const refAfter = store.nodeOutputs[executionId]
|
|
|
|
expect(refAfter).not.toBe(refBefore)
|
|
expect(refAfter?.images).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
describe('nodeOutputStore restoreOutputs', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.clearAllMocks()
|
|
app.nodeOutputs = {}
|
|
app.nodePreviewImages = {}
|
|
})
|
|
|
|
it('should keep reactivity after restoreOutputs followed by setNodeOutputsByExecutionId', () => {
|
|
const store = useNodeOutputStore()
|
|
|
|
// Simulate execution: set outputs for node "4" (e.g., PreviewImage)
|
|
const executionOutput = createMockOutputs([
|
|
{ filename: 'ComfyUI_00001.png', subfolder: '', type: 'temp' }
|
|
])
|
|
const savedOutputs: Record<string, ExecutedWsMessage['output']> = {
|
|
'4': executionOutput
|
|
}
|
|
|
|
// Simulate undo: restoreOutputs makes app.nodeOutputs and the ref
|
|
// share the same underlying object if not handled correctly.
|
|
store.restoreOutputs(savedOutputs)
|
|
|
|
expect(store.nodeOutputs['4']).toStrictEqual(executionOutput)
|
|
expect(store.nodeOutputs['3']).toBeUndefined()
|
|
|
|
// Simulate widget callback setting outputs for node "3" (e.g., LoadImage)
|
|
const widgetOutput = createMockOutputs([
|
|
{ filename: 'example.png', subfolder: '', type: 'input' }
|
|
])
|
|
store.setNodeOutputsByExecutionId('3', widgetOutput)
|
|
|
|
// The reactive store must reflect the new output.
|
|
// Before the fix, the raw write to app.nodeOutputs would mutate the
|
|
// proxy's target before the proxy write, causing Vue to skip the
|
|
// reactivity update.
|
|
expect(store.nodeOutputs['3']).toStrictEqual(widgetOutput)
|
|
expect(app.nodeOutputs['3']).toStrictEqual(widgetOutput)
|
|
})
|
|
})
|
|
|
|
describe('nodeOutputStore input preview preservation', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.clearAllMocks()
|
|
app.nodeOutputs = {}
|
|
app.nodePreviewImages = {}
|
|
})
|
|
|
|
it('should preserve input preview when execution sends empty output', () => {
|
|
const store = useNodeOutputStore()
|
|
const executionId = '3'
|
|
|
|
const inputPreview = createMockOutputs([
|
|
{ filename: 'example.png', subfolder: '', type: 'input' }
|
|
])
|
|
store.setNodeOutputsByExecutionId(executionId, inputPreview)
|
|
|
|
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
|
|
|
const emptyExecutionOutput = createMockOutputs()
|
|
store.setNodeOutputsByExecutionId(executionId, emptyExecutionOutput)
|
|
|
|
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
|
expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe(
|
|
'example.png'
|
|
)
|
|
})
|
|
|
|
it('should preserve input preview when execution sends output with empty images array', () => {
|
|
const store = useNodeOutputStore()
|
|
const executionId = '3'
|
|
|
|
const inputPreview = createMockOutputs([
|
|
{ filename: 'example.png', subfolder: '', type: 'input' }
|
|
])
|
|
store.setNodeOutputsByExecutionId(executionId, inputPreview)
|
|
|
|
const emptyImagesOutput = createMockOutputs([])
|
|
store.setNodeOutputsByExecutionId(executionId, emptyImagesOutput)
|
|
|
|
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
|
expect(store.nodeOutputs[executionId]?.images?.[0].type).toBe('input')
|
|
})
|
|
|
|
it('should allow execution output with images to overwrite input preview', () => {
|
|
const store = useNodeOutputStore()
|
|
const executionId = '3'
|
|
|
|
const inputPreview = createMockOutputs([
|
|
{ filename: 'example.png', subfolder: '', type: 'input' }
|
|
])
|
|
store.setNodeOutputsByExecutionId(executionId, inputPreview)
|
|
|
|
const executionOutput = createMockOutputs([
|
|
{ filename: 'output.png', subfolder: '', type: 'output' }
|
|
])
|
|
store.setNodeOutputsByExecutionId(executionId, executionOutput)
|
|
|
|
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
|
expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe(
|
|
'output.png'
|
|
)
|
|
})
|
|
|
|
it('should not preserve non-input outputs from being overwritten', () => {
|
|
const store = useNodeOutputStore()
|
|
const executionId = '4'
|
|
|
|
const tempOutput = createMockOutputs([
|
|
{ filename: 'temp.png', subfolder: '', type: 'temp' }
|
|
])
|
|
store.setNodeOutputsByExecutionId(executionId, tempOutput)
|
|
|
|
const emptyOutput = createMockOutputs()
|
|
store.setNodeOutputsByExecutionId(executionId, emptyOutput)
|
|
|
|
expect(store.nodeOutputs[executionId]?.images).toBeUndefined()
|
|
})
|
|
|
|
it('should pass through non-image fields while preserving input preview images', () => {
|
|
const store = useNodeOutputStore()
|
|
const executionId = '5'
|
|
|
|
const inputPreview = createMockOutputs([
|
|
{ filename: 'example.png', subfolder: '', type: 'input' }
|
|
])
|
|
store.setNodeOutputsByExecutionId(executionId, inputPreview)
|
|
|
|
const videoOutput: ExecutedWsMessage['output'] = {
|
|
video: [{ filename: 'output.mp4', subfolder: '', type: 'output' }]
|
|
}
|
|
store.setNodeOutputsByExecutionId(executionId, videoOutput)
|
|
|
|
expect(store.nodeOutputs[executionId]?.images).toHaveLength(1)
|
|
expect(store.nodeOutputs[executionId]?.images?.[0].filename).toBe(
|
|
'example.png'
|
|
)
|
|
expect(store.nodeOutputs[executionId]?.video).toHaveLength(1)
|
|
expect(store.nodeOutputs[executionId]?.video?.[0].filename).toBe(
|
|
'output.mp4'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('nodeOutputStore getPreviewParam', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.clearAllMocks()
|
|
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(false)
|
|
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(false)
|
|
})
|
|
|
|
it('should return empty string if output is animated', () => {
|
|
const store = useNodeOutputStore()
|
|
vi.mocked(litegraphUtil.isAnimatedOutput).mockReturnValue(true)
|
|
const node = createMockNode()
|
|
const outputs = createMockOutputs([{ filename: 'img.png' }])
|
|
expect(store.getPreviewParam(node, outputs)).toBe('')
|
|
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should return empty string if isVideoNode returns true', () => {
|
|
const store = useNodeOutputStore()
|
|
vi.mocked(litegraphUtil.isVideoNode).mockReturnValue(true)
|
|
const node = createMockNode()
|
|
const outputs = createMockOutputs([{ filename: 'img.png' }])
|
|
expect(store.getPreviewParam(node, outputs)).toBe('')
|
|
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should return empty string if outputs.images is undefined', () => {
|
|
const store = useNodeOutputStore()
|
|
const node = createMockNode()
|
|
const outputs: ExecutedWsMessage['output'] = {}
|
|
expect(store.getPreviewParam(node, outputs)).toBe('')
|
|
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should return empty string if outputs.images is empty', () => {
|
|
const store = useNodeOutputStore()
|
|
const node = createMockNode()
|
|
const outputs = createMockOutputs([])
|
|
expect(store.getPreviewParam(node, outputs)).toBe('')
|
|
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should return empty string if outputs.images contains SVG images', () => {
|
|
const store = useNodeOutputStore()
|
|
const node = createMockNode()
|
|
const outputs = createMockOutputs([{ filename: 'img.svg' }])
|
|
expect(store.getPreviewParam(node, outputs)).toBe('')
|
|
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should return format param for standard image outputs', () => {
|
|
const store = useNodeOutputStore()
|
|
const node = createMockNode()
|
|
const outputs = createMockOutputs([{ filename: 'img.png' }])
|
|
expect(store.getPreviewParam(node, outputs)).toBe('&format=test_webp')
|
|
expect(vi.mocked(app).getPreviewFormatParam).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should return format param for multiple standard images', () => {
|
|
const store = useNodeOutputStore()
|
|
const node = createMockNode()
|
|
const outputs = createMockOutputs([
|
|
{ filename: 'img1.png' },
|
|
{ filename: 'img2.jpg' }
|
|
])
|
|
expect(store.getPreviewParam(node, outputs)).toBe('&format=test_webp')
|
|
expect(vi.mocked(app).getPreviewFormatParam).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe('nodeOutputStore syncLegacyNodeImgs', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.clearAllMocks()
|
|
LiteGraph.vueNodesMode = false
|
|
})
|
|
|
|
it('should not sync when vueNodesMode is disabled', () => {
|
|
const store = useNodeOutputStore()
|
|
const mockNode = createMockNode({ id: 1 })
|
|
const mockImg = document.createElement('img')
|
|
|
|
mockGetNodeById.mockReturnValue(mockNode)
|
|
|
|
store.syncLegacyNodeImgs(1, mockImg, 0)
|
|
|
|
expect(mockNode.imgs).toBeUndefined()
|
|
expect(mockNode.imageIndex).toBeUndefined()
|
|
})
|
|
|
|
it('should sync node.imgs when vueNodesMode is enabled', () => {
|
|
LiteGraph.vueNodesMode = true
|
|
const store = useNodeOutputStore()
|
|
const mockNode = createMockNode({ id: 1 })
|
|
const mockImg = document.createElement('img')
|
|
|
|
mockGetNodeById.mockReturnValue(mockNode)
|
|
|
|
store.syncLegacyNodeImgs(1, mockImg, 0)
|
|
|
|
expect(mockNode.imgs).toEqual([mockImg])
|
|
expect(mockNode.imageIndex).toBe(0)
|
|
})
|
|
|
|
it('should sync with correct activeIndex', () => {
|
|
LiteGraph.vueNodesMode = true
|
|
const store = useNodeOutputStore()
|
|
const mockNode = createMockNode({ id: 42 })
|
|
const mockImg = document.createElement('img')
|
|
|
|
mockGetNodeById.mockReturnValue(mockNode)
|
|
|
|
store.syncLegacyNodeImgs(42, mockImg, 3)
|
|
|
|
expect(mockNode.imgs).toEqual([mockImg])
|
|
expect(mockNode.imageIndex).toBe(3)
|
|
})
|
|
|
|
it('should handle string nodeId', () => {
|
|
LiteGraph.vueNodesMode = true
|
|
const store = useNodeOutputStore()
|
|
const mockNode = createMockNode({ id: 123 })
|
|
const mockImg = document.createElement('img')
|
|
|
|
mockGetNodeById.mockReturnValue(mockNode)
|
|
|
|
store.syncLegacyNodeImgs('123', mockImg, 0)
|
|
|
|
expect(mockGetNodeById).toHaveBeenCalledWith(123)
|
|
expect(mockNode.imgs).toEqual([mockImg])
|
|
})
|
|
|
|
it('should not throw when node is not found', () => {
|
|
LiteGraph.vueNodesMode = true
|
|
const store = useNodeOutputStore()
|
|
const mockImg = document.createElement('img')
|
|
|
|
mockGetNodeById.mockReturnValue(undefined)
|
|
|
|
expect(() => store.syncLegacyNodeImgs(999, mockImg, 0)).not.toThrow()
|
|
})
|
|
|
|
it('should default activeIndex to 0', () => {
|
|
LiteGraph.vueNodesMode = true
|
|
const store = useNodeOutputStore()
|
|
const mockNode = createMockNode({ id: 1 })
|
|
const mockImg = document.createElement('img')
|
|
|
|
mockGetNodeById.mockReturnValue(mockNode)
|
|
|
|
store.syncLegacyNodeImgs(1, mockImg)
|
|
|
|
expect(mockNode.imageIndex).toBe(0)
|
|
})
|
|
})
|