Files
ComfyUI_frontend/src/stores/subgraphStore.test.ts
Christian Byrne 337e0486ea feat: client-side distribution filtering for blueprint subgraphs (#8686)
Adds client-side filtering of blueprint subgraphs by distribution.

**Changes:**
- Added `includeOnDistributions` typed field to `GlobalSubgraphData` in
`api.ts`
- Distribution detection: `isCloud → 'cloud'`, `isDesktop → 'desktop'`,
else `'localhost'`
- Filters subgraphs before loading — excluded blueprints are never
fetched

**Depends on:** Comfy-Org/workflow_templates schema update (merge first)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8686-feat-client-side-distribution-filtering-for-blueprint-subgraphs-2ff6d73d365081d29f79c4e3cab174ac)
by [Unito](https://www.unito.io)
2026-02-20 17:02:54 -08:00

398 lines
12 KiB
TypeScript

import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import type { GlobalSubgraphData } from '@/scripts/api'
import type { ExportedSubgraph } from '@/lib/litegraph/src/types/serialisation'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { createTestingPinia } from '@pinia/testing'
const mockDistributionTypes = vi.hoisted(() => ({
isCloud: false,
isDesktop: false
}))
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
// Mock telemetry to break circular dependency (telemetry → workflowStore → app → telemetry)
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {
getUserData: vi.fn(),
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
getGlobalSubgraphs: vi.fn(),
apiURL: vi.fn(),
addEventListener: vi.fn()
}
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
prompt: () => 'testname',
confirm: () => true
}))
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => ({
getCanvas: () => comfyApp.canvas
}))
}))
// Mock comfyApp globally for the store setup
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
_deserializeItems: vi.fn((i) => i),
ds: { visible_area: [0, 0, 0, 0] },
selected_nodes: null
},
loadGraphData: vi.fn()
}
}))
const mockGraph = {
nodes: [{ type: '123' }],
definitions: { subgraphs: [{ id: '123' }] }
}
describe('useSubgraphStore', () => {
let store: ReturnType<typeof useSubgraphStore>
async function mockFetch(
filenames: Record<string, unknown>,
globalSubgraphs: Record<string, GlobalSubgraphData> = {}
) {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue(
Object.keys(filenames).map((filename) => ({
path: filename,
modified: new Date().getTime(),
size: 1 // size !== -1 for remote workflows
}))
)
vi.mocked(api).getUserData = vi.fn((f) =>
Promise.resolve({
status: 200,
text: () => Promise.resolve(JSON.stringify(filenames[f.slice(10)]))
} as Response)
)
vi.mocked(api.getGlobalSubgraphs).mockResolvedValue(globalSubgraphs)
return await store.fetchSubgraphs()
}
beforeEach(() => {
mockDistributionTypes.isCloud = false
mockDistributionTypes.isDesktop = false
setActivePinia(createTestingPinia({ stubActions: false }))
store = useSubgraphStore()
vi.clearAllMocks()
})
it('should allow publishing of a subgraph', async () => {
//mock canvas to provide a minimal subgraphNode
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph!
graph.add(subgraphNode)
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => {
const serializedSubgraph = {
...subgraph.serialize(),
links: [],
groups: [],
version: 1
} as Partial<ExportedSubgraph> as ExportedSubgraph
return {
nodes: [subgraphNode.serialize()],
subgraphs: [serializedSubgraph]
}
})
//mock saving of file
vi.mocked(api.storeUserData).mockResolvedValue({
status: 200,
json: () =>
Promise.resolve({
path: 'subgraphs/testname.json',
modified: Date.now(),
size: 2
})
} as Response)
await mockFetch({ 'testname.json': mockGraph })
//Dialogue service already mocked
await store.publishSubgraph()
expect(api.storeUserData).toHaveBeenCalled()
})
it('should display published nodes in the node library', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(
useNodeDefStore().nodeDefs.filter(
(d) => d.category === 'Subgraph Blueprints/User'
)
).toHaveLength(1)
})
it('should allow subgraphs to be edited', async () => {
await mockFetch({ 'test.json': mockGraph })
await store.editBlueprint(store.typePrefix + 'test')
//check active graph
expect(comfyApp.loadGraphData).toHaveBeenCalled()
})
it('should allow subgraphs to be added to graph', async () => {
//mock
await mockFetch({ 'test.json': mockGraph })
const res = useLitegraphService().addNodeOnGraph({
name: 'SubgraphBlueprint.test'
} as ComfyNodeDefV1)
expect(res).toBeTruthy()
})
it('should identify user blueprints as non-global', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(store.isGlobalBlueprint('test')).toBe(false)
})
it('should identify global blueprints loaded from getGlobalSubgraphs', async () => {
await mockFetch(
{},
{
global_test: {
name: 'Global Test Blueprint',
info: { node_pack: 'comfy_essentials' },
data: JSON.stringify(mockGraph)
}
}
)
expect(store.isGlobalBlueprint('global_test')).toBe(true)
})
it('should return false for non-existent blueprints', async () => {
await mockFetch({ 'test.json': mockGraph })
expect(store.isGlobalBlueprint('nonexistent')).toBe(false)
})
describe('search_aliases support', () => {
it('should include search_aliases from workflow extra', async () => {
const mockGraphWithAliases = {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123' }]
},
extra: {
BlueprintSearchAliases: ['alias1', 'alias2', 'my workflow']
}
}
await mockFetch({ 'test-with-aliases.json': mockGraphWithAliases })
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.test-with-aliases'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.search_aliases).toEqual([
'alias1',
'alias2',
'my workflow'
])
})
it('should include search_aliases from global blueprint info', async () => {
await mockFetch(
{},
{
global_with_aliases: {
name: 'Global With Aliases',
info: {
node_pack: 'comfy_essentials',
search_aliases: ['global alias', 'test alias']
},
data: JSON.stringify(mockGraph)
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.global_with_aliases'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.search_aliases).toEqual(['global alias', 'test alias'])
})
it('should not have search_aliases if not provided', async () => {
await mockFetch({ 'test.json': mockGraph })
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.test'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.search_aliases).toBeUndefined()
})
it('should include description from workflow extra', async () => {
const mockGraphWithDescription = {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123' }]
},
extra: {
BlueprintDescription: 'This is a test blueprint'
}
}
await mockFetch({
'test-with-description.json': mockGraphWithDescription
})
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.test-with-description'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.description).toBe('This is a test blueprint')
})
it('should not duplicate metadata in both workflow extra and subgraph extra when publishing', async () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph!
graph.add(subgraphNode)
// Set metadata on the subgraph's extra (as the commands do)
subgraph.extra = {
BlueprintDescription: 'Test description',
BlueprintSearchAliases: ['alias1', 'alias2']
}
vi.mocked(comfyApp.canvas).selectedItems = new Set([subgraphNode])
vi.mocked(comfyApp.canvas)._serializeItems = vi.fn(() => {
const serializedSubgraph = {
...subgraph.serialize(),
links: [],
groups: [],
version: 1
} as Partial<ExportedSubgraph> as ExportedSubgraph
return {
nodes: [subgraphNode.serialize()],
subgraphs: [serializedSubgraph]
}
})
let savedWorkflowData: Record<string, unknown> | null = null
vi.mocked(api.storeUserData).mockImplementation(async (_path, data) => {
savedWorkflowData = JSON.parse(data as string)
return {
status: 200,
json: () =>
Promise.resolve({
path: 'subgraphs/testname.json',
modified: Date.now(),
size: 2
})
} as Response
})
await mockFetch({ 'testname.json': mockGraph })
await store.publishSubgraph()
expect(savedWorkflowData).not.toBeNull()
// Metadata should be in top-level extra
expect(savedWorkflowData!.extra).toEqual({
BlueprintDescription: 'Test description',
BlueprintSearchAliases: ['alias1', 'alias2']
})
// Metadata should NOT be in subgraph's extra
const definitions = savedWorkflowData!.definitions as {
subgraphs: Array<{ extra?: Record<string, unknown> }>
}
const subgraphExtra = definitions.subgraphs[0]?.extra
expect(subgraphExtra?.BlueprintDescription).toBeUndefined()
expect(subgraphExtra?.BlueprintSearchAliases).toBeUndefined()
})
})
describe('global blueprint filtering', () => {
function globalBlueprint(
overrides: Partial<GlobalSubgraphData['info']> = {}
): GlobalSubgraphData {
return {
name: 'Filtered Blueprint',
info: { node_pack: 'test_pack', ...overrides },
data: JSON.stringify(mockGraph)
}
}
it('should exclude blueprints with requiresCustomNodes on non-cloud', async () => {
await mockFetch(
{},
{
bp: globalBlueprint({ requiresCustomNodes: ['custom-node-pack'] })
}
)
expect(store.isGlobalBlueprint('bp')).toBe(false)
})
it('should include blueprints with requiresCustomNodes on cloud', async () => {
mockDistributionTypes.isCloud = true
await mockFetch(
{},
{
bp: globalBlueprint({ requiresCustomNodes: ['custom-node-pack'] })
}
)
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
it('should include blueprints with empty requiresCustomNodes everywhere', async () => {
await mockFetch({}, { bp: globalBlueprint({ requiresCustomNodes: [] }) })
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
it('should exclude blueprints whose includeOnDistributions does not match', async () => {
await mockFetch(
{},
{
bp: globalBlueprint({
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Cloud]
})
}
)
expect(store.isGlobalBlueprint('bp')).toBe(false)
})
it('should include blueprints whose includeOnDistributions matches current distribution', async () => {
await mockFetch(
{},
{
bp: globalBlueprint({
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Local]
})
}
)
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
it('should include blueprints on desktop when includeOnDistributions has desktop', async () => {
mockDistributionTypes.isDesktop = true
await mockFetch(
{},
{
bp: globalBlueprint({
includeOnDistributions: [TemplateIncludeOnDistributionEnum.Desktop]
})
}
)
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
it('should include blueprints with no filtering fields', async () => {
await mockFetch({}, { bp: globalBlueprint() })
expect(store.isGlobalBlueprint('bp')).toBe(true)
})
})
})