mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-09 01:20:09 +00:00
[feat] Move partial execution to the backend and make work with subgraphs (#4624)
This commit is contained in:
@@ -29,7 +29,11 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { getAllNonIoNodesInSubgraph } from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
getAllNonIoNodesInSubgraph,
|
||||
getExecutionIdsForSelectedNodes
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
@@ -363,10 +367,10 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
versionAdded: '1.19.6',
|
||||
function: async () => {
|
||||
const batchCount = useQueueSettingsStore().batchCount
|
||||
const queueNodeIds = getSelectedNodes()
|
||||
.filter((node) => node.constructor.nodeData?.output_node)
|
||||
.map((node) => node.id)
|
||||
if (queueNodeIds.length === 0) {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
const selectedOutputNodes = filterOutputNodes(selectedNodes)
|
||||
|
||||
if (selectedOutputNodes.length === 0) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.nothingToQueue'),
|
||||
@@ -375,7 +379,11 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
})
|
||||
return
|
||||
}
|
||||
await app.queuePrompt(0, batchCount, queueNodeIds)
|
||||
|
||||
// Get execution IDs for all selected output nodes and their descendants
|
||||
const executionIds =
|
||||
getExecutionIdsForSelectedNodes(selectedOutputNodes)
|
||||
await app.queuePrompt(0, batchCount, executionIds)
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -35,11 +35,13 @@ import type {
|
||||
NodeId
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { WorkflowTemplates } from '@/types/workflowTemplateTypes'
|
||||
|
||||
interface QueuePromptRequestBody {
|
||||
client_id: string
|
||||
prompt: ComfyApiWorkflow
|
||||
partial_execution_targets?: NodeExecutionId[]
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
workflow: ComfyWorkflowJSON
|
||||
@@ -80,6 +82,18 @@ interface QueuePromptRequestBody {
|
||||
number?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for queuePrompt method
|
||||
*/
|
||||
interface QueuePromptOptions {
|
||||
/**
|
||||
* Optional list of node execution IDs to execute (partial execution).
|
||||
* Each ID represents a node's position in nested subgraphs.
|
||||
* Format: Colon-separated path of node IDs (e.g., "123:456:789")
|
||||
*/
|
||||
partialExecutionTargets?: NodeExecutionId[]
|
||||
}
|
||||
|
||||
/** Dictionary of Frontend-generated API calls */
|
||||
interface FrontendApiCalls {
|
||||
graphChanged: ComfyWorkflowJSON
|
||||
@@ -610,18 +624,23 @@ export class ComfyApi extends EventTarget {
|
||||
/**
|
||||
* Queues a prompt to be executed
|
||||
* @param {number} number The index at which to queue the prompt, passing -1 will insert the prompt at the front of the queue
|
||||
* @param {object} prompt The prompt data to queue
|
||||
* @param {object} data The prompt data to queue
|
||||
* @param {QueuePromptOptions} options Optional execution options
|
||||
* @throws {PromptExecutionError} If the prompt fails to execute
|
||||
*/
|
||||
async queuePrompt(
|
||||
number: number,
|
||||
data: { output: ComfyApiWorkflow; workflow: ComfyWorkflowJSON }
|
||||
data: { output: ComfyApiWorkflow; workflow: ComfyWorkflowJSON },
|
||||
options?: QueuePromptOptions
|
||||
): Promise<PromptResponse> {
|
||||
const { output: prompt, workflow } = data
|
||||
|
||||
const body: QueuePromptRequestBody = {
|
||||
client_id: this.clientId ?? '', // TODO: Unify clientId access
|
||||
prompt,
|
||||
...(options?.partialExecutionTargets && {
|
||||
partial_execution_targets: options.partialExecutionTargets
|
||||
}),
|
||||
extra_data: {
|
||||
auth_token_comfy_org: this.authToken,
|
||||
api_key_comfy_org: this.apiKey,
|
||||
|
||||
@@ -59,6 +59,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
|
||||
import { ExtensionManager } from '@/types/extensionTypes'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
import {
|
||||
@@ -127,7 +128,7 @@ export class ComfyApp {
|
||||
#queueItems: {
|
||||
number: number
|
||||
batchCount: number
|
||||
queueNodeIds?: NodeId[]
|
||||
queueNodeIds?: NodeExecutionId[]
|
||||
}[] = []
|
||||
/**
|
||||
* If the queue is currently being processed
|
||||
@@ -1239,20 +1240,16 @@ export class ComfyApp {
|
||||
})
|
||||
}
|
||||
|
||||
async graphToPrompt(
|
||||
graph = this.graph,
|
||||
options: { queueNodeIds?: NodeId[] } = {}
|
||||
) {
|
||||
async graphToPrompt(graph = this.graph) {
|
||||
return graphToPrompt(graph, {
|
||||
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave'),
|
||||
queueNodeIds: options.queueNodeIds
|
||||
sortNodes: useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
|
||||
})
|
||||
}
|
||||
|
||||
async queuePrompt(
|
||||
number: number,
|
||||
batchCount: number = 1,
|
||||
queueNodeIds?: NodeId[]
|
||||
queueNodeIds?: NodeExecutionId[]
|
||||
): Promise<boolean> {
|
||||
this.#queueItems.push({ number, batchCount, queueNodeIds })
|
||||
|
||||
@@ -1281,11 +1278,13 @@ export class ComfyApp {
|
||||
executeWidgetsCallback(subgraph.nodes, 'beforeQueued')
|
||||
}
|
||||
|
||||
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
|
||||
const p = await this.graphToPrompt(this.graph)
|
||||
try {
|
||||
api.authToken = comfyOrgAuthToken
|
||||
api.apiKey = comfyOrgApiKey ?? undefined
|
||||
const res = await api.queuePrompt(number, p)
|
||||
const res = await api.queuePrompt(number, p, {
|
||||
partialExecutionTargets: queueNodeIds
|
||||
})
|
||||
delete api.authToken
|
||||
delete api.apiKey
|
||||
executionStore.lastNodeErrors = res.node_errors ?? null
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type {
|
||||
ExecutableLGraphNode,
|
||||
ExecutionId,
|
||||
LGraph,
|
||||
NodeId
|
||||
LGraph
|
||||
} from '@comfyorg/litegraph'
|
||||
import {
|
||||
ExecutableNodeDTO,
|
||||
@@ -18,31 +17,6 @@ import type {
|
||||
import { ExecutableGroupNodeDTO, isGroupNode } from './executableGroupNodeDto'
|
||||
import { compressWidgetInputSlots } from './litegraphUtil'
|
||||
|
||||
/**
|
||||
* Recursively target node's parent nodes to the new output.
|
||||
* @param nodeId The node id to add.
|
||||
* @param oldOutput The old output.
|
||||
* @param newOutput The new output.
|
||||
* @returns The new output.
|
||||
*/
|
||||
function recursiveAddNodes(
|
||||
nodeId: NodeId,
|
||||
oldOutput: ComfyApiWorkflow,
|
||||
newOutput: ComfyApiWorkflow
|
||||
) {
|
||||
const currentId = String(nodeId)
|
||||
const currentNode = oldOutput[currentId]!
|
||||
if (newOutput[currentId] == null) {
|
||||
newOutput[currentId] = currentNode
|
||||
for (const inputValue of Object.values(currentNode.inputs || [])) {
|
||||
if (Array.isArray(inputValue)) {
|
||||
recursiveAddNodes(inputValue[0], oldOutput, newOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
return newOutput
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the current graph workflow for sending to the API.
|
||||
* @note Node widgets are updated before serialization to prepare queueing.
|
||||
@@ -50,14 +24,13 @@ function recursiveAddNodes(
|
||||
* @param graph The graph to convert.
|
||||
* @param options The options for the conversion.
|
||||
* - `sortNodes`: Whether to sort the nodes by execution order.
|
||||
* - `queueNodeIds`: The output nodes to execute. Execute all output nodes if not provided.
|
||||
* @returns The workflow and node links
|
||||
*/
|
||||
export const graphToPrompt = async (
|
||||
graph: LGraph,
|
||||
options: { sortNodes?: boolean; queueNodeIds?: NodeId[] } = {}
|
||||
options: { sortNodes?: boolean } = {}
|
||||
): Promise<{ workflow: ComfyWorkflowJSON; output: ComfyApiWorkflow }> => {
|
||||
const { sortNodes = false, queueNodeIds } = options
|
||||
const { sortNodes = false } = options
|
||||
|
||||
for (const node of graph.computeExecutionOrder(false)) {
|
||||
const innerNodes = node.getInnerNodes
|
||||
@@ -104,7 +77,7 @@ export const graphToPrompt = async (
|
||||
nodeDtoMap.set(dto.id, dto)
|
||||
}
|
||||
|
||||
let output: ComfyApiWorkflow = {}
|
||||
const output: ComfyApiWorkflow = {}
|
||||
// Process nodes in order of execution
|
||||
for (const node of nodeDtoMap.values()) {
|
||||
// Don't serialize muted nodes
|
||||
@@ -180,14 +153,5 @@ export const graphToPrompt = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Partial execution
|
||||
if (queueNodeIds?.length) {
|
||||
const newOutput = {}
|
||||
for (const queueNodeId of queueNodeIds) {
|
||||
recursiveAddNodes(queueNodeId, output, newOutput)
|
||||
}
|
||||
output = newOutput
|
||||
}
|
||||
|
||||
return { workflow: workflow as ComfyWorkflowJSON, output }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LGraph, LGraphNode, Subgraph } from '@comfyorg/litegraph'
|
||||
|
||||
import type { NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { parseNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
import { isSubgraphIoNode } from './typeGuardUtil'
|
||||
@@ -351,3 +351,106 @@ export function mapSubgraphNodes<T>(
|
||||
export function getAllNonIoNodesInSubgraph(subgraph: Subgraph): LGraphNode[] {
|
||||
return subgraph.nodes.filter((node) => !isSubgraphIoNode(node))
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs depth-first traversal of nodes and their subgraphs.
|
||||
* Generic visitor pattern that can be used for various node processing tasks.
|
||||
*
|
||||
* @param nodes - Starting nodes for traversal
|
||||
* @param visitor - Function called for each node with its context
|
||||
* @param expandSubgraphs - Whether to traverse into subgraph nodes (default: true)
|
||||
*/
|
||||
export function traverseNodesDepthFirst<T>(
|
||||
nodes: LGraphNode[],
|
||||
visitor: (node: LGraphNode, context: T) => T,
|
||||
initialContext: T,
|
||||
expandSubgraphs: boolean = true
|
||||
): void {
|
||||
type StackItem = { node: LGraphNode; context: T }
|
||||
const stack: StackItem[] = []
|
||||
|
||||
// Initialize stack with starting nodes
|
||||
for (const node of nodes) {
|
||||
stack.push({ node, context: initialContext })
|
||||
}
|
||||
|
||||
// Process stack iteratively (DFS)
|
||||
while (stack.length > 0) {
|
||||
const { node, context } = stack.pop()!
|
||||
|
||||
// Visit node and get updated context for children
|
||||
const childContext = visitor(node, context)
|
||||
|
||||
// If it's a subgraph and we should expand, add children to stack
|
||||
if (expandSubgraphs && node.isSubgraphNode?.() && node.subgraph) {
|
||||
// Process children in reverse order to maintain left-to-right DFS processing
|
||||
// when popping from stack (LIFO). Iterate backwards to avoid array reversal.
|
||||
const children = node.subgraph.nodes
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
stack.push({ node: children[i], context: childContext })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects nodes with custom data during depth-first traversal.
|
||||
* Generic collector that can gather any type of data per node.
|
||||
*
|
||||
* @param nodes - Starting nodes for traversal
|
||||
* @param collector - Function that returns data to collect for each node
|
||||
* @param contextBuilder - Function that builds context for child nodes
|
||||
* @param expandSubgraphs - Whether to traverse into subgraph nodes
|
||||
* @returns Array of collected data
|
||||
*/
|
||||
export function collectFromNodes<T, C>(
|
||||
nodes: LGraphNode[],
|
||||
collector: (node: LGraphNode, context: C) => T | null,
|
||||
contextBuilder: (node: LGraphNode, parentContext: C) => C,
|
||||
initialContext: C,
|
||||
expandSubgraphs: boolean = true
|
||||
): T[] {
|
||||
const results: T[] = []
|
||||
|
||||
traverseNodesDepthFirst(
|
||||
nodes,
|
||||
(node, context) => {
|
||||
const data = collector(node, context)
|
||||
if (data !== null) {
|
||||
results.push(data)
|
||||
}
|
||||
return contextBuilder(node, context)
|
||||
},
|
||||
initialContext,
|
||||
expandSubgraphs
|
||||
)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects execution IDs for selected nodes and all their descendants.
|
||||
* Uses the generic DFS traversal with optimized string building.
|
||||
*
|
||||
* @param selectedNodes - The selected nodes to process
|
||||
* @returns Array of execution IDs for selected nodes and all nodes within selected subgraphs
|
||||
*/
|
||||
export function getExecutionIdsForSelectedNodes(
|
||||
selectedNodes: LGraphNode[]
|
||||
): NodeExecutionId[] {
|
||||
return collectFromNodes(
|
||||
selectedNodes,
|
||||
// Collector: build execution ID for each node
|
||||
(node, parentExecutionId: string): NodeExecutionId => {
|
||||
const nodeId = String(node.id)
|
||||
return parentExecutionId ? `${parentExecutionId}:${nodeId}` : nodeId
|
||||
},
|
||||
// Context builder: pass execution ID to children
|
||||
(node, parentExecutionId: string) => {
|
||||
const nodeId = String(node.id)
|
||||
return parentExecutionId ? `${parentExecutionId}:${nodeId}` : nodeId
|
||||
},
|
||||
'', // Initial context: empty parent execution ID
|
||||
true // Expand subgraphs
|
||||
)
|
||||
}
|
||||
|
||||
21
src/utils/nodeFilterUtil.ts
Normal file
21
src/utils/nodeFilterUtil.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
|
||||
/**
|
||||
* Checks if a node is an output node.
|
||||
* Output nodes are nodes that have the output_node flag set in their nodeData.
|
||||
*
|
||||
* @param node - The node to check
|
||||
* @returns True if the node is an output node, false otherwise
|
||||
*/
|
||||
export const isOutputNode = (node: LGraphNode) =>
|
||||
node.constructor.nodeData?.output_node
|
||||
|
||||
/**
|
||||
* Filters nodes to find only output nodes.
|
||||
* Output nodes are nodes that have the output_node flag set in their nodeData.
|
||||
*
|
||||
* @param nodes - Array of nodes to filter
|
||||
* @returns Array of output nodes only
|
||||
*/
|
||||
export const filterOutputNodes = (nodes: LGraphNode[]): LGraphNode[] =>
|
||||
nodes.filter(isOutputNode)
|
||||
@@ -3,11 +3,13 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
collectAllNodes,
|
||||
collectFromNodes,
|
||||
findNodeInHierarchy,
|
||||
findSubgraphByUuid,
|
||||
forEachNode,
|
||||
forEachSubgraphNode,
|
||||
getAllNonIoNodesInSubgraph,
|
||||
getExecutionIdsForSelectedNodes,
|
||||
getLocalNodeIdFromExecutionId,
|
||||
getNodeByExecutionId,
|
||||
getNodeByLocatorId,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
mapAllNodes,
|
||||
mapSubgraphNodes,
|
||||
parseExecutionId,
|
||||
traverseNodesDepthFirst,
|
||||
traverseSubgraphPath,
|
||||
triggerCallbackOnAllNodes,
|
||||
visitGraphNodes
|
||||
@@ -807,5 +810,378 @@ describe('graphTraversalUtil', () => {
|
||||
expect(nonIoNodes).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('traverseNodesDepthFirst', () => {
|
||||
it('should traverse nodes in depth-first order', () => {
|
||||
const visited: string[] = []
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2'),
|
||||
createMockNode('3')
|
||||
]
|
||||
|
||||
traverseNodesDepthFirst(
|
||||
nodes,
|
||||
(node, context) => {
|
||||
visited.push(`${node.id}:${context}`)
|
||||
return `${context}-${node.id}`
|
||||
},
|
||||
'root'
|
||||
)
|
||||
|
||||
expect(visited).toEqual(['3:root', '2:root', '1:root']) // DFS processes in LIFO order
|
||||
})
|
||||
|
||||
it('should traverse into subgraphs when expandSubgraphs is true', () => {
|
||||
const visited: string[] = []
|
||||
const subNode = createMockNode('sub1')
|
||||
const subgraph = createMockSubgraph('sub-uuid', [subNode])
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
traverseNodesDepthFirst(
|
||||
nodes,
|
||||
(node, depth: number) => {
|
||||
visited.push(`${node.id}:${depth}`)
|
||||
return depth + 1
|
||||
},
|
||||
0
|
||||
)
|
||||
|
||||
expect(visited).toEqual(['2:0', 'sub1:1', '1:0']) // DFS: last node first, then its children
|
||||
})
|
||||
|
||||
it('should skip subgraphs when expandSubgraphs is false', () => {
|
||||
const visited: string[] = []
|
||||
const subNode = createMockNode('sub1')
|
||||
const subgraph = createMockSubgraph('sub-uuid', [subNode])
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
traverseNodesDepthFirst(
|
||||
nodes,
|
||||
(node, context) => {
|
||||
visited.push(String(node.id))
|
||||
return context
|
||||
},
|
||||
null,
|
||||
false
|
||||
)
|
||||
|
||||
expect(visited).toEqual(['2', '1']) // DFS processes in LIFO order
|
||||
expect(visited).not.toContain('sub1')
|
||||
})
|
||||
|
||||
it('should handle deeply nested subgraphs', () => {
|
||||
const visited: string[] = []
|
||||
|
||||
const deepNode = createMockNode('300')
|
||||
const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode])
|
||||
|
||||
const midNode = createMockNode('200', {
|
||||
isSubgraph: true,
|
||||
subgraph: deepSubgraph
|
||||
})
|
||||
const midSubgraph = createMockSubgraph('mid-uuid', [midNode])
|
||||
|
||||
const topNode = createMockNode('100', {
|
||||
isSubgraph: true,
|
||||
subgraph: midSubgraph
|
||||
})
|
||||
|
||||
traverseNodesDepthFirst(
|
||||
[topNode],
|
||||
(node, path: string) => {
|
||||
visited.push(`${node.id}:${path}`)
|
||||
return path ? `${path}/${node.id}` : String(node.id)
|
||||
},
|
||||
''
|
||||
)
|
||||
|
||||
expect(visited).toEqual(['100:', '200:100', '300:100/200'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectFromNodes', () => {
|
||||
it('should collect data from all nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2'),
|
||||
createMockNode('3')
|
||||
]
|
||||
|
||||
const results = collectFromNodes(
|
||||
nodes,
|
||||
(node) => `node-${node.id}`,
|
||||
(_node, context) => context,
|
||||
null
|
||||
)
|
||||
|
||||
expect(results).toEqual(['node-3', 'node-2', 'node-1']) // DFS processes in LIFO order
|
||||
})
|
||||
|
||||
it('should filter out null results', () => {
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2'),
|
||||
createMockNode('3')
|
||||
]
|
||||
|
||||
const results = collectFromNodes(
|
||||
nodes,
|
||||
(node) => (Number(node.id) > 1 ? `node-${node.id}` : null),
|
||||
(_node, context) => context,
|
||||
null
|
||||
)
|
||||
|
||||
expect(results).toEqual(['node-3', 'node-2']) // DFS processes in LIFO order, node-1 filtered out
|
||||
})
|
||||
|
||||
it('should collect from subgraphs with context', () => {
|
||||
const subNodes = [createMockNode('10'), createMockNode('11')]
|
||||
const subgraph = createMockSubgraph('sub-uuid', subNodes)
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
const results = collectFromNodes(
|
||||
nodes,
|
||||
(node, prefix: string) => `${prefix}${node.id}`,
|
||||
(node, prefix: string) => `${prefix}${node.id}-`,
|
||||
'node-',
|
||||
true
|
||||
)
|
||||
|
||||
expect(results).toEqual([
|
||||
'node-2',
|
||||
'node-2-10', // Actually processes in original order within subgraph
|
||||
'node-2-11',
|
||||
'node-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('should not expand subgraphs when expandSubgraphs is false', () => {
|
||||
const subNodes = [createMockNode('10'), createMockNode('11')]
|
||||
const subgraph = createMockSubgraph('sub-uuid', subNodes)
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
const results = collectFromNodes(
|
||||
nodes,
|
||||
(node) => String(node.id),
|
||||
(_node, context) => context,
|
||||
null,
|
||||
false
|
||||
)
|
||||
|
||||
expect(results).toEqual(['2', '1']) // DFS processes in LIFO order
|
||||
})
|
||||
})
|
||||
|
||||
describe('getExecutionIdsForSelectedNodes', () => {
|
||||
it('should return simple IDs for top-level nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode('123'),
|
||||
createMockNode('456'),
|
||||
createMockNode('789')
|
||||
]
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes(nodes)
|
||||
|
||||
expect(executionIds).toEqual(['789', '456', '123']) // DFS processes in LIFO order
|
||||
})
|
||||
|
||||
it('should expand subgraph nodes to include all children', () => {
|
||||
const subNodes = [createMockNode('10'), createMockNode('11')]
|
||||
const subgraph = createMockSubgraph('sub-uuid', subNodes)
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph })
|
||||
]
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes(nodes)
|
||||
|
||||
expect(executionIds).toEqual(['2', '2:10', '2:11', '1']) // DFS: node 2 first, then its children
|
||||
})
|
||||
|
||||
it('should handle deeply nested subgraphs correctly', () => {
|
||||
const deepNodes = [createMockNode('30'), createMockNode('31')]
|
||||
const deepSubgraph = createMockSubgraph('deep-uuid', deepNodes)
|
||||
|
||||
const midNode = createMockNode('20', {
|
||||
isSubgraph: true,
|
||||
subgraph: deepSubgraph
|
||||
})
|
||||
const midSubgraph = createMockSubgraph('mid-uuid', [midNode])
|
||||
|
||||
const topNode = createMockNode('10', {
|
||||
isSubgraph: true,
|
||||
subgraph: midSubgraph
|
||||
})
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes([topNode])
|
||||
|
||||
expect(executionIds).toEqual(['10', '10:20', '10:20:30', '10:20:31'])
|
||||
})
|
||||
|
||||
it('should handle mixed selection of regular and subgraph nodes', () => {
|
||||
const subNodes = [createMockNode('100'), createMockNode('101')]
|
||||
const subgraph = createMockSubgraph('sub-uuid', subNodes)
|
||||
|
||||
const nodes = [
|
||||
createMockNode('1'),
|
||||
createMockNode('2', { isSubgraph: true, subgraph }),
|
||||
createMockNode('3')
|
||||
]
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes(nodes)
|
||||
|
||||
expect(executionIds).toEqual([
|
||||
'3',
|
||||
'2',
|
||||
'2:100', // Subgraph children in original order
|
||||
'2:101',
|
||||
'1'
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle empty selection', () => {
|
||||
const executionIds = getExecutionIdsForSelectedNodes([])
|
||||
expect(executionIds).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle subgraph with no children', () => {
|
||||
const emptySubgraph = createMockSubgraph('empty-uuid', [])
|
||||
const node = createMockNode('1', {
|
||||
isSubgraph: true,
|
||||
subgraph: emptySubgraph
|
||||
})
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes([node])
|
||||
|
||||
expect(executionIds).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('should handle nodes with very long execution paths', () => {
|
||||
// Create a chain of 10 nested subgraphs
|
||||
let currentSubgraph = createMockSubgraph('deep-10', [
|
||||
createMockNode('10')
|
||||
])
|
||||
|
||||
for (let i = 9; i >= 1; i--) {
|
||||
const node = createMockNode(`${i}0`, {
|
||||
isSubgraph: true,
|
||||
subgraph: currentSubgraph
|
||||
})
|
||||
currentSubgraph = createMockSubgraph(`deep-${i}`, [node])
|
||||
}
|
||||
|
||||
const topNode = createMockNode('1', {
|
||||
isSubgraph: true,
|
||||
subgraph: currentSubgraph
|
||||
})
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes([topNode])
|
||||
|
||||
expect(executionIds).toHaveLength(11)
|
||||
expect(executionIds[0]).toBe('1')
|
||||
expect(executionIds[10]).toBe('1:10:20:30:40:50:60:70:80:90:10')
|
||||
})
|
||||
|
||||
it('should handle duplicate node IDs in different subgraphs', () => {
|
||||
// Create two subgraphs with nodes that have the same IDs
|
||||
const subgraph1 = createMockSubgraph('sub1-uuid', [
|
||||
createMockNode('100'),
|
||||
createMockNode('101')
|
||||
])
|
||||
|
||||
const subgraph2 = createMockSubgraph('sub2-uuid', [
|
||||
createMockNode('100'), // Same ID as in subgraph1
|
||||
createMockNode('101') // Same ID as in subgraph1
|
||||
])
|
||||
|
||||
const nodes = [
|
||||
createMockNode('1', { isSubgraph: true, subgraph: subgraph1 }),
|
||||
createMockNode('2', { isSubgraph: true, subgraph: subgraph2 })
|
||||
]
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes(nodes)
|
||||
|
||||
expect(executionIds).toEqual([
|
||||
'2',
|
||||
'2:100',
|
||||
'2:101',
|
||||
'1',
|
||||
'1:100',
|
||||
'1:101'
|
||||
])
|
||||
})
|
||||
|
||||
it('should handle subgraphs with many children efficiently', () => {
|
||||
// Create a subgraph with 100 nodes
|
||||
const manyNodes = []
|
||||
for (let i = 0; i < 100; i++) {
|
||||
manyNodes.push(createMockNode(`child-${i}`))
|
||||
}
|
||||
const bigSubgraph = createMockSubgraph('big-uuid', manyNodes)
|
||||
|
||||
const node = createMockNode('parent', {
|
||||
isSubgraph: true,
|
||||
subgraph: bigSubgraph
|
||||
})
|
||||
|
||||
const start = performance.now()
|
||||
const executionIds = getExecutionIdsForSelectedNodes([node])
|
||||
const duration = performance.now() - start
|
||||
|
||||
expect(executionIds).toHaveLength(101)
|
||||
expect(executionIds[0]).toBe('parent')
|
||||
expect(executionIds[100]).toBe('parent:child-99') // Due to backward iteration optimization
|
||||
|
||||
// Should complete quickly even with many nodes
|
||||
expect(duration).toBeLessThan(50)
|
||||
})
|
||||
|
||||
it('should handle selection of nodes at different depths', () => {
|
||||
// Create a complex nested structure
|
||||
const deepNode = createMockNode('300')
|
||||
const deepSubgraph = createMockSubgraph('deep-uuid', [deepNode])
|
||||
|
||||
const midNode1 = createMockNode('201')
|
||||
const midNode2 = createMockNode('202', {
|
||||
isSubgraph: true,
|
||||
subgraph: deepSubgraph
|
||||
})
|
||||
const midSubgraph = createMockSubgraph('mid-uuid', [midNode1, midNode2])
|
||||
|
||||
const topNode = createMockNode('100', {
|
||||
isSubgraph: true,
|
||||
subgraph: midSubgraph
|
||||
})
|
||||
|
||||
// Select nodes at different nesting levels
|
||||
const selectedNodes = [
|
||||
createMockNode('1'), // Root level
|
||||
topNode, // Contains subgraph
|
||||
createMockNode('2') // Root level
|
||||
]
|
||||
|
||||
const executionIds = getExecutionIdsForSelectedNodes(selectedNodes)
|
||||
|
||||
expect(executionIds).toContain('1')
|
||||
expect(executionIds).toContain('2')
|
||||
expect(executionIds).toContain('100')
|
||||
expect(executionIds).toContain('100:201')
|
||||
expect(executionIds).toContain('100:202')
|
||||
expect(executionIds).toContain('100:202:300')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
114
tests-ui/tests/utils/nodeFilterUtil.test.ts
Normal file
114
tests-ui/tests/utils/nodeFilterUtil.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { filterOutputNodes, isOutputNode } from '@/utils/nodeFilterUtil'
|
||||
|
||||
describe('nodeFilterUtil', () => {
|
||||
// Helper to create a mock node
|
||||
const createMockNode = (
|
||||
id: number,
|
||||
isOutputNode: boolean = false
|
||||
): LGraphNode => {
|
||||
// Create a custom class with the nodeData static property
|
||||
class MockNode extends LGraphNode {
|
||||
static nodeData = isOutputNode ? { output_node: true } : {}
|
||||
}
|
||||
|
||||
const node = new MockNode('')
|
||||
node.id = id
|
||||
return node
|
||||
}
|
||||
|
||||
describe('filterOutputNodes', () => {
|
||||
it('should return empty array when given empty array', () => {
|
||||
const result = filterOutputNodes([])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should filter out non-output nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode(1, false),
|
||||
createMockNode(2, true),
|
||||
createMockNode(3, false),
|
||||
createMockNode(4, true)
|
||||
]
|
||||
|
||||
const result = filterOutputNodes(nodes)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((n) => n.id)).toEqual([2, 4])
|
||||
})
|
||||
|
||||
it('should return all nodes if all are output nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode(1, true),
|
||||
createMockNode(2, true),
|
||||
createMockNode(3, true)
|
||||
]
|
||||
|
||||
const result = filterOutputNodes(nodes)
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result).toEqual(nodes)
|
||||
})
|
||||
|
||||
it('should return empty array if no output nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode(1, false),
|
||||
createMockNode(2, false),
|
||||
createMockNode(3, false)
|
||||
]
|
||||
|
||||
const result = filterOutputNodes(nodes)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle nodes without nodeData', () => {
|
||||
// Create a plain LGraphNode without custom constructor
|
||||
const node = new LGraphNode('')
|
||||
node.id = 1
|
||||
|
||||
const result = filterOutputNodes([node])
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should handle nodes with undefined output_node', () => {
|
||||
class MockNodeWithOtherData extends LGraphNode {
|
||||
static nodeData = { someOtherProperty: true }
|
||||
}
|
||||
|
||||
const node = new MockNodeWithOtherData('')
|
||||
node.id = 1
|
||||
|
||||
const result = filterOutputNodes([node])
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOutputNode', () => {
|
||||
it('should filter selected nodes to only output nodes', () => {
|
||||
const selectedNodes = [
|
||||
createMockNode(1, false),
|
||||
createMockNode(2, true),
|
||||
createMockNode(3, false),
|
||||
createMockNode(4, true),
|
||||
createMockNode(5, false)
|
||||
]
|
||||
|
||||
const result = selectedNodes.filter(isOutputNode)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.map((n) => n.id)).toEqual([2, 4])
|
||||
})
|
||||
|
||||
it('should handle empty selection', () => {
|
||||
const emptyNodes: LGraphNode[] = []
|
||||
const result = emptyNodes.filter(isOutputNode)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle selection with no output nodes', () => {
|
||||
const selectedNodes = [createMockNode(1, false), createMockNode(2, false)]
|
||||
|
||||
const result = selectedNodes.filter(isOutputNode)
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user