diff --git a/package-lock.json b/package-lock.json index 9ec9420c2..cc90ba109 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e2652cf23..01d6c4882 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/graph/SelectionOverlay.vue b/src/components/graph/SelectionOverlay.vue index aa1f11c76..d998dad06 100644 --- a/src/components/graph/SelectionOverlay.vue +++ b/src/components/graph/SelectionOverlay.vue @@ -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, () => { diff --git a/src/composables/canvas/useSelectedLiteGraphItems.ts b/src/composables/canvas/useSelectedLiteGraphItems.ts new file mode 100644 index 000000000..3d44fe089 --- /dev/null +++ b/src/composables/canvas/useSelectedLiteGraphItems.ts @@ -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 + ): Set => { + const result = new Set() + 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 => { + 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 + } +} diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index 54eaa2a32..d18e96f00 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -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) } }, { diff --git a/src/scripts/api.ts b/src/scripts/api.ts index c38f23139..e83eb9f8b 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -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 { 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, diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 19f51c270..fe6023405 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -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 { 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 diff --git a/src/utils/executionUtil.ts b/src/utils/executionUtil.ts index a0047d716..727a631ca 100644 --- a/src/utils/executionUtil.ts +++ b/src/utils/executionUtil.ts @@ -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 } } diff --git a/src/utils/graphTraversalUtil.ts b/src/utils/graphTraversalUtil.ts index 5fcf0b369..7124c4a60 100644 --- a/src/utils/graphTraversalUtil.ts +++ b/src/utils/graphTraversalUtil.ts @@ -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( 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( + 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( + 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 + ) +} diff --git a/src/utils/nodeFilterUtil.ts b/src/utils/nodeFilterUtil.ts new file mode 100644 index 000000000..2456edd50 --- /dev/null +++ b/src/utils/nodeFilterUtil.ts @@ -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) diff --git a/tests-ui/tests/composables/canvas/useSelectedLiteGraphItems.test.ts b/tests-ui/tests/composables/canvas/useSelectedLiteGraphItems.test.ts new file mode 100644 index 000000000..4bbbc906b --- /dev/null +++ b/tests-ui/tests/composables/canvas/useSelectedLiteGraphItems.test.ts @@ -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 + let mockCanvas: any + + beforeEach(() => { + setActivePinia(createPinia()) + canvasStore = useCanvasStore() + + // Mock canvas with selectedItems Set + mockCanvas = { + selectedItems: new Set() + } + + // 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([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([reroute1, reroute2]) + const filtered = filterSelectableItems(items) + + expect(filtered.size).toBe(0) + }) + + it('should handle empty set', () => { + const { filterSelectableItems } = useSelectedLiteGraphItems() + const items = new Set() + 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) + }) + }) +}) diff --git a/tests-ui/tests/utils/graphTraversalUtil.test.ts b/tests-ui/tests/utils/graphTraversalUtil.test.ts index b3b3dd23a..48ff7351b 100644 --- a/tests-ui/tests/utils/graphTraversalUtil.test.ts +++ b/tests-ui/tests/utils/graphTraversalUtil.test.ts @@ -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') + }) + }) }) }) diff --git a/tests-ui/tests/utils/nodeFilterUtil.test.ts b/tests-ui/tests/utils/nodeFilterUtil.test.ts new file mode 100644 index 000000000..94f62b6f2 --- /dev/null +++ b/tests-ui/tests/utils/nodeFilterUtil.test.ts @@ -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) + }) + }) +})