mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-11 00:10:40 +00:00
## Summary The `computed` in `usePromotedPreviews` only tracked `nodeOutputs` as a reactive dependency. GLSL live previews (and other preview-only sources) write to `nodePreviewImages` instead of `nodeOutputs`, so promoted preview widgets on SubgraphNodes never re-evaluated when live previews updated. ## Changes **Production** (`usePromotedPreviews.ts` — 3-line fix): - Add `nodePreviewImages[locatorId]` as a second reactive dependency alongside `nodeOutputs[locatorId]` - Guard now passes when *either* source has data, not just `nodeOutputs` **Tests** (`usePromotedPreviews.test.ts`): - Add `nodePreviewImages` to mock store type and factory - Add `seedPreviewImages()` helper - Add `getNodeImageUrls.mockReset()` in `beforeEach` for proper test isolation - Two new test cases: - `returns preview when only nodePreviewImages exist (e.g. GLSL live preview)` - `recomputes when preview images are populated after first evaluation` - Clean up existing tests to use hoisted `getNodeImageUrls` mock directly instead of `vi.mocked(useNodeOutputStore().getNodeImageUrls)` ## What this supersedes This is a minimal re-implementation of #9461. That PR also modified `promotionStore.ts` with a `_version`/`_touch()` monotonic counter to manually force reactivity — that approach is dropped here as it is an anti-pattern (manually managing reactivity counters instead of using Vue's built-in reactivity system). The promotionStore changes were not needed for this fix. ## Related - Supersedes #9461 - Prerequisite work: #9198 (add GLSLShader to canvas image preview node types) - Upstream feature: #9201 (useGLSLPreview composable) - Adjacent: #9435 (centralize node image rendering state in NodeImageStore) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10165-fix-track-nodePreviewImages-in-usePromotedPreviews-for-GLSL-live-preview-propagation-3266d73d365081cd87d0d12c4c041907) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
322 lines
9.5 KiB
TypeScript
322 lines
9.5 KiB
TypeScript
import { createTestingPinia } from '@pinia/testing'
|
|
import { setActivePinia } from 'pinia'
|
|
import { reactive } from 'vue'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import {
|
|
createTestSubgraph,
|
|
createTestSubgraphNode
|
|
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
|
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
|
import { usePromotionStore } from '@/stores/promotionStore'
|
|
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
|
|
|
import { usePromotedPreviews } from './usePromotedPreviews'
|
|
|
|
type MockNodeOutputStore = Pick<
|
|
ReturnType<typeof useNodeOutputStore>,
|
|
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
|
|
>
|
|
|
|
const getNodeImageUrls = vi.hoisted(() =>
|
|
vi.fn<MockNodeOutputStore['getNodeImageUrls']>()
|
|
)
|
|
const useNodeOutputStoreMock = vi.hoisted(() =>
|
|
vi.fn<() => MockNodeOutputStore>()
|
|
)
|
|
|
|
vi.mock('@/stores/nodeOutputStore', () => {
|
|
return {
|
|
useNodeOutputStore: useNodeOutputStoreMock
|
|
}
|
|
})
|
|
|
|
function createMockNodeOutputStore(): MockNodeOutputStore {
|
|
return {
|
|
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
|
|
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
|
|
getNodeImageUrls
|
|
}
|
|
}
|
|
|
|
function createSetup() {
|
|
const subgraph = createTestSubgraph()
|
|
const subgraphNode = createTestSubgraphNode(subgraph)
|
|
return { subgraph, subgraphNode }
|
|
}
|
|
|
|
function addInteriorNode(
|
|
setup: ReturnType<typeof createSetup>,
|
|
options: {
|
|
id: number
|
|
previewMediaType?: 'image' | 'video' | 'audio' | 'model'
|
|
} = { id: 10 }
|
|
): LGraphNode {
|
|
const node = new LGraphNode('test')
|
|
node.id = options.id
|
|
if (options.previewMediaType) {
|
|
node.previewMediaType = options.previewMediaType
|
|
}
|
|
setup.subgraph.add(node)
|
|
return node
|
|
}
|
|
|
|
function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
|
|
const store = useNodeOutputStore()
|
|
for (const nodeId of nodeIds) {
|
|
const locatorId = createNodeLocatorId(subgraphId, nodeId)
|
|
store.nodeOutputs[locatorId] = {
|
|
images: [{ filename: 'output.png' }]
|
|
}
|
|
}
|
|
}
|
|
|
|
function seedPreviewImages(
|
|
subgraphId: string,
|
|
entries: Array<{ nodeId: number | string; urls: string[] }>
|
|
) {
|
|
const store = useNodeOutputStore()
|
|
for (const { nodeId, urls } of entries) {
|
|
const locatorId = createNodeLocatorId(subgraphId, nodeId)
|
|
store.nodePreviewImages[locatorId] = urls
|
|
}
|
|
}
|
|
|
|
describe(usePromotedPreviews, () => {
|
|
let nodeOutputStore: MockNodeOutputStore
|
|
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
vi.clearAllMocks()
|
|
getNodeImageUrls.mockReset()
|
|
|
|
nodeOutputStore = createMockNodeOutputStore()
|
|
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
|
|
})
|
|
|
|
it('returns empty array for non-SubgraphNode', () => {
|
|
const node = new LGraphNode('test')
|
|
const { promotedPreviews } = usePromotedPreviews(() => node)
|
|
expect(promotedPreviews.value).toEqual([])
|
|
})
|
|
|
|
it('returns empty array for null node', () => {
|
|
const { promotedPreviews } = usePromotedPreviews(() => null)
|
|
expect(promotedPreviews.value).toEqual([])
|
|
})
|
|
|
|
it('returns empty array when no $$ promotions exist', () => {
|
|
const setup = createSetup()
|
|
addInteriorNode(setup, { id: 10 })
|
|
usePromotionStore().promote(
|
|
setup.subgraphNode.rootGraph.id,
|
|
setup.subgraphNode.id,
|
|
'10',
|
|
'seed'
|
|
)
|
|
|
|
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
|
expect(promotedPreviews.value).toEqual([])
|
|
})
|
|
|
|
it('returns image preview for promoted $$ widget with outputs', () => {
|
|
const setup = createSetup()
|
|
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
|
usePromotionStore().promote(
|
|
setup.subgraphNode.rootGraph.id,
|
|
setup.subgraphNode.id,
|
|
'10',
|
|
'$$canvas-image-preview'
|
|
)
|
|
|
|
const mockUrls = ['/view?filename=output.png']
|
|
seedOutputs(setup.subgraph.id, [10])
|
|
getNodeImageUrls.mockReturnValue(mockUrls)
|
|
|
|
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
|
expect(promotedPreviews.value).toEqual([
|
|
{
|
|
interiorNodeId: '10',
|
|
widgetName: '$$canvas-image-preview',
|
|
type: 'image',
|
|
urls: mockUrls
|
|
}
|
|
])
|
|
})
|
|
|
|
it('returns video type when interior node has video previewMediaType', () => {
|
|
const setup = createSetup()
|
|
addInteriorNode(setup, { id: 10, previewMediaType: 'video' })
|
|
usePromotionStore().promote(
|
|
setup.subgraphNode.rootGraph.id,
|
|
setup.subgraphNode.id,
|
|
'10',
|
|
'$$canvas-image-preview'
|
|
)
|
|
|
|
seedOutputs(setup.subgraph.id, [10])
|
|
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
|
|
|
|
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
|
expect(promotedPreviews.value[0].type).toBe('video')
|
|
})
|
|
|
|
it('returns audio type when interior node has audio previewMediaType', () => {
|
|
const setup = createSetup()
|
|
addInteriorNode(setup, { id: 10, previewMediaType: 'audio' })
|
|
usePromotionStore().promote(
|
|
setup.subgraphNode.rootGraph.id,
|
|
setup.subgraphNode.id,
|
|
'10',
|
|
'$$canvas-image-preview'
|
|
)
|
|
|
|
seedOutputs(setup.subgraph.id, [10])
|
|
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
|
|
|
|
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
|
expect(promotedPreviews.value[0].type).toBe('audio')
|
|
})
|
|
|
|
it('returns separate entries for multiple promoted $$ widgets', () => {
|
|
const setup = createSetup()
|
|
const node10 = addInteriorNode(setup, {
|
|
id: 10,
|
|
previewMediaType: 'image'
|
|
})
|
|
const node20 = addInteriorNode(setup, {
|
|
id: 20,
|
|
previewMediaType: 'image'
|
|
})
|
|
usePromotionStore().promote(
|
|
setup.subgraphNode.rootGraph.id,
|
|
setup.subgraphNode.id,
|
|
'10',
|
|
'$$canvas-image-preview'
|
|
)
|
|
usePromotionStore().promote(
|
|
setup.subgraphNode.rootGraph.id,
|
|
setup.subgraphNode.id,
|
|
'20',
|
|
'$$canvas-image-preview'
|
|
)
|
|
|
|
seedOutputs(setup.subgraph.id, [10, 20])
|
|
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
|
|
if (node === node10) return ['/view?a=1']
|
|
if (node === node20) return ['/view?b=2']
|
|
return undefined
|
|
})
|
|
|
|
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
|
expect(promotedPreviews.value).toHaveLength(2)
|
|
expect(promotedPreviews.value[0].urls).toEqual(['/view?a=1'])
|
|
expect(promotedPreviews.value[1].urls).toEqual(['/view?b=2'])
|
|
})
|
|
|
|
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
|
|
const setup = createSetup()
|
|
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
|
usePromotionStore().promote(
|
|
setup.subgraphNode.rootGraph.id,
|
|
setup.subgraphNode.id,
|
|
'10',
|
|
'$$canvas-image-preview'
|
|
)
|
|
|
|
const blobUrl = 'blob:http://localhost/glsl-preview'
|
|
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
|
|
getNodeImageUrls.mockReturnValue([blobUrl])
|
|
|
|
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
|
expect(promotedPreviews.value).toEqual([
|
|
{
|
|
interiorNodeId: '10',
|
|
widgetName: '$$canvas-image-preview',
|
|
type: 'image',
|
|
urls: [blobUrl]
|
|
}
|
|
])
|
|
})
|
|
|
|
it('recomputes when preview images are populated after first evaluation', () => {
|
|
const setup = createSetup()
|
|
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
|
|
usePromotionStore().promote(
|
|
setup.subgraphNode.rootGraph.id,
|
|
setup.subgraphNode.id,
|
|
'10',
|
|
'$$canvas-image-preview'
|
|
)
|
|
|
|
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
|
expect(promotedPreviews.value).toEqual([])
|
|
|
|
const blobUrl = 'blob:http://localhost/glsl-preview'
|
|
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
|
|
getNodeImageUrls.mockReturnValue([blobUrl])
|
|
|
|
expect(promotedPreviews.value).toEqual([
|
|
{
|
|
interiorNodeId: '10',
|
|
widgetName: '$$canvas-image-preview',
|
|
type: 'image',
|
|
urls: [blobUrl]
|
|
}
|
|
])
|
|
})
|
|
|
|
it('skips interior nodes with no image output', () => {
|
|
const setup = createSetup()
|
|
addInteriorNode(setup, { id: 10 })
|
|
usePromotionStore().promote(
|
|
setup.subgraphNode.rootGraph.id,
|
|
setup.subgraphNode.id,
|
|
'10',
|
|
'$$canvas-image-preview'
|
|
)
|
|
|
|
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
|
expect(promotedPreviews.value).toEqual([])
|
|
})
|
|
|
|
it('skips missing interior nodes', () => {
|
|
const setup = createSetup()
|
|
usePromotionStore().promote(
|
|
setup.subgraphNode.rootGraph.id,
|
|
setup.subgraphNode.id,
|
|
'99',
|
|
'$$canvas-image-preview'
|
|
)
|
|
|
|
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
|
expect(promotedPreviews.value).toEqual([])
|
|
})
|
|
|
|
it('ignores non-$$ promoted widgets', () => {
|
|
const setup = createSetup()
|
|
addInteriorNode(setup, { id: 10 })
|
|
usePromotionStore().promote(
|
|
setup.subgraphNode.rootGraph.id,
|
|
setup.subgraphNode.id,
|
|
'10',
|
|
'seed'
|
|
)
|
|
usePromotionStore().promote(
|
|
setup.subgraphNode.rootGraph.id,
|
|
setup.subgraphNode.id,
|
|
'10',
|
|
'$$canvas-image-preview'
|
|
)
|
|
|
|
const mockUrls = ['/view?filename=img.png']
|
|
seedOutputs(setup.subgraph.id, [10])
|
|
getNodeImageUrls.mockReturnValue(mockUrls)
|
|
|
|
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
|
|
expect(promotedPreviews.value).toHaveLength(1)
|
|
expect(promotedPreviews.value[0].urls).toEqual(mockUrls)
|
|
})
|
|
})
|