Merge branch 'main' into scroll-templates-better

This commit is contained in:
Johnpaul Chiwetelu
2025-08-01 03:31:23 +01:00
committed by GitHub
13 changed files with 959 additions and 67 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.2",
"version": "1.25.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.2",
"version": "1.25.3",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.25.2",
"version": "1.25.3",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -17,26 +17,28 @@ import { createBounds } from '@comfyorg/litegraph'
import { whenever } from '@vueuse/core'
import { ref, watch } from 'vue'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { useCanvasStore } from '@/stores/graphStore'
const canvasStore = useCanvasStore()
const { style, updatePosition } = useAbsolutePosition()
const { getSelectableItems } = useSelectedLiteGraphItems()
const visible = ref(false)
const showBorder = ref(false)
const positionSelectionOverlay = () => {
const { selectedItems } = canvasStore.getCanvas()
showBorder.value = selectedItems.size > 1
const selectableItems = getSelectableItems()
showBorder.value = selectableItems.size > 1
if (!selectedItems.size) {
if (!selectableItems.size) {
visible.value = false
return
}
visible.value = true
const bounds = createBounds(selectedItems)
const bounds = createBounds(selectableItems)
if (bounds) {
updatePosition({
pos: [bounds[0], bounds[1]],
@@ -45,7 +47,6 @@ const positionSelectionOverlay = () => {
}
}
// Register listener on canvas creation.
whenever(
() => canvasStore.getCanvas().state.selectionChanged,
() => {

View File

@@ -0,0 +1,71 @@
import { Positionable, Reroute } from '@comfyorg/litegraph'
import { useCanvasStore } from '@/stores/graphStore'
/**
* Composable for handling selected LiteGraph items filtering and operations.
* This provides utilities for working with selected items on the canvas,
* including filtering out items that should not be included in selection operations.
*/
export function useSelectedLiteGraphItems() {
const canvasStore = useCanvasStore()
/**
* Items that should not show in the selection overlay are ignored.
* @param item - The item to check.
* @returns True if the item should be ignored, false otherwise.
*/
const isIgnoredItem = (item: Positionable): boolean => {
return item instanceof Reroute
}
/**
* Filter out items that should not show in the selection overlay.
* @param items - The Set of items to filter.
* @returns The filtered Set of items.
*/
const filterSelectableItems = (
items: Set<Positionable>
): Set<Positionable> => {
const result = new Set<Positionable>()
for (const item of items) {
if (!isIgnoredItem(item)) {
result.add(item)
}
}
return result
}
/**
* Get the filtered selected items from the canvas.
* @returns The filtered Set of selected items.
*/
const getSelectableItems = (): Set<Positionable> => {
const { selectedItems } = canvasStore.getCanvas()
return filterSelectableItems(selectedItems)
}
/**
* Check if there are any selectable items.
* @returns True if there are selectable items, false otherwise.
*/
const hasSelectableItems = (): boolean => {
return getSelectableItems().size > 0
}
/**
* Check if there are multiple selectable items.
* @returns True if there are multiple selectable items, false otherwise.
*/
const hasMultipleSelectableItems = (): boolean => {
return getSelectableItems().size > 1
}
return {
isIgnoredItem,
filterSelectableItems,
getSelectableItems,
hasSelectableItems,
hasMultipleSelectableItems
}
}

View File

@@ -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)
}
},
{

View File

@@ -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,

View File

@@ -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

View File

@@ -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 }
}

View File

@@ -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
)
}

View 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)

View File

@@ -0,0 +1,216 @@
import { Positionable, Reroute } from '@comfyorg/litegraph'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useCanvasStore } from '@/stores/graphStore'
// Mock the litegraph module
vi.mock('@comfyorg/litegraph', () => ({
Reroute: class Reroute {
constructor() {}
}
}))
// Mock Positionable objects
// @ts-expect-error - Mock implementation for testing
class MockNode implements Positionable {
pos: [number, number]
size: [number, number]
constructor(
pos: [number, number] = [0, 0],
size: [number, number] = [100, 100]
) {
this.pos = pos
this.size = size
}
}
class MockReroute extends Reroute implements Positionable {
// @ts-expect-error - Override for testing
override pos: [number, number]
size: [number, number]
constructor(
pos: [number, number] = [0, 0],
size: [number, number] = [20, 20]
) {
// @ts-expect-error - Mock constructor
super()
this.pos = pos
this.size = size
}
}
describe('useSelectedLiteGraphItems', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let mockCanvas: any
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
// Mock canvas with selectedItems Set
mockCanvas = {
selectedItems: new Set<Positionable>()
}
// Mock getCanvas to return our mock canvas
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
})
describe('isIgnoredItem', () => {
it('should return true for Reroute instances', () => {
const { isIgnoredItem } = useSelectedLiteGraphItems()
const reroute = new MockReroute()
expect(isIgnoredItem(reroute)).toBe(true)
})
it('should return false for non-Reroute items', () => {
const { isIgnoredItem } = useSelectedLiteGraphItems()
const node = new MockNode()
// @ts-expect-error - Test mock
expect(isIgnoredItem(node)).toBe(false)
})
})
describe('filterSelectableItems', () => {
it('should filter out Reroute items', () => {
const { filterSelectableItems } = useSelectedLiteGraphItems()
const node1 = new MockNode([0, 0])
const node2 = new MockNode([100, 100])
const reroute = new MockReroute([50, 50])
// @ts-expect-error - Test mocks
const items = new Set<Positionable>([node1, node2, reroute])
const filtered = filterSelectableItems(items)
expect(filtered.size).toBe(2)
// @ts-expect-error - Test mocks
expect(filtered.has(node1)).toBe(true)
// @ts-expect-error - Test mocks
expect(filtered.has(node2)).toBe(true)
expect(filtered.has(reroute)).toBe(false)
})
it('should return empty set when all items are ignored', () => {
const { filterSelectableItems } = useSelectedLiteGraphItems()
const reroute1 = new MockReroute([0, 0])
const reroute2 = new MockReroute([50, 50])
const items = new Set<Positionable>([reroute1, reroute2])
const filtered = filterSelectableItems(items)
expect(filtered.size).toBe(0)
})
it('should handle empty set', () => {
const { filterSelectableItems } = useSelectedLiteGraphItems()
const items = new Set<Positionable>()
const filtered = filterSelectableItems(items)
expect(filtered.size).toBe(0)
})
})
describe('methods', () => {
it('getSelectableItems should return only non-ignored items', () => {
const { getSelectableItems } = useSelectedLiteGraphItems()
const node1 = new MockNode()
const node2 = new MockNode()
const reroute = new MockReroute()
mockCanvas.selectedItems.add(node1)
mockCanvas.selectedItems.add(node2)
mockCanvas.selectedItems.add(reroute)
const selectableItems = getSelectableItems()
expect(selectableItems.size).toBe(2)
// @ts-expect-error - Test mock
expect(selectableItems.has(node1)).toBe(true)
// @ts-expect-error - Test mock
expect(selectableItems.has(node2)).toBe(true)
expect(selectableItems.has(reroute)).toBe(false)
})
it('hasSelectableItems should be true when there are selectable items', () => {
const { hasSelectableItems } = useSelectedLiteGraphItems()
const node = new MockNode()
expect(hasSelectableItems()).toBe(false)
mockCanvas.selectedItems.add(node)
expect(hasSelectableItems()).toBe(true)
})
it('hasSelectableItems should be false when only ignored items are selected', () => {
const { hasSelectableItems } = useSelectedLiteGraphItems()
const reroute = new MockReroute()
mockCanvas.selectedItems.add(reroute)
expect(hasSelectableItems()).toBe(false)
})
it('hasMultipleSelectableItems should be true when there are 2+ selectable items', () => {
const { hasMultipleSelectableItems } = useSelectedLiteGraphItems()
const node1 = new MockNode()
const node2 = new MockNode()
expect(hasMultipleSelectableItems()).toBe(false)
mockCanvas.selectedItems.add(node1)
expect(hasMultipleSelectableItems()).toBe(false)
mockCanvas.selectedItems.add(node2)
expect(hasMultipleSelectableItems()).toBe(true)
})
it('hasMultipleSelectableItems should not count ignored items', () => {
const { hasMultipleSelectableItems } = useSelectedLiteGraphItems()
const node = new MockNode()
const reroute1 = new MockReroute()
const reroute2 = new MockReroute()
mockCanvas.selectedItems.add(node)
mockCanvas.selectedItems.add(reroute1)
mockCanvas.selectedItems.add(reroute2)
// Even though there are 3 items total, only 1 is selectable
expect(hasMultipleSelectableItems()).toBe(false)
})
})
describe('dynamic behavior', () => {
it('methods should reflect changes when selectedItems change', () => {
const {
getSelectableItems,
hasSelectableItems,
hasMultipleSelectableItems
} = useSelectedLiteGraphItems()
const node1 = new MockNode()
const node2 = new MockNode()
expect(hasSelectableItems()).toBe(false)
expect(hasMultipleSelectableItems()).toBe(false)
// Add first node
mockCanvas.selectedItems.add(node1)
expect(hasSelectableItems()).toBe(true)
expect(hasMultipleSelectableItems()).toBe(false)
expect(getSelectableItems().size).toBe(1)
// Add second node
mockCanvas.selectedItems.add(node2)
expect(hasSelectableItems()).toBe(true)
expect(hasMultipleSelectableItems()).toBe(true)
expect(getSelectableItems().size).toBe(2)
// Remove a node
mockCanvas.selectedItems.delete(node1)
expect(hasSelectableItems()).toBe(true)
expect(hasMultipleSelectableItems()).toBe(false)
expect(getSelectableItems().size).toBe(1)
})
})
})

View File

@@ -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')
})
})
})
})

View 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)
})
})
})