mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-21 23:34:31 +00:00
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)
398 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|