Compare commits

..

1 Commits

Author SHA1 Message Date
pythongosssss
34e07fb481 Add support for custom icons in menu
- Update Browse Templates to use custom icon
2025-08-17 17:40:02 +01:00
15 changed files with 208 additions and 405 deletions

View File

@@ -178,6 +178,79 @@ test.describe('Menu', () => {
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
expect(await comfyPage.getVisibleToastCount()).toBe(1)
})
test('Browse Templates custom icon is visible and matches sidebar icon', async ({
comfyPage
}) => {
// Open the top menu
await comfyPage.menu.topbar.openTopbarMenu()
const menu = comfyPage.page.locator('.comfy-command-menu')
// Find the Browse Templates menu item
const browseTemplatesItem = menu.locator(
'.p-menubar-item-label:text-is("Browse Templates")'
)
await expect(browseTemplatesItem).toBeVisible()
// Check that the Browse Templates item has an icon
const menuIcon = browseTemplatesItem
.locator('..')
.locator('.p-menubar-item-icon')
.first()
await expect(menuIcon).toBeVisible()
// Get the icon's tag name and class to verify it's a component (not a string icon)
const menuIconType = await menuIcon.evaluate((el) => {
// If it's a Vue component, it will not have pi/mdi classes
// and should be an SVG or custom component
const tagName = el.tagName.toLowerCase()
const classes = el.className || ''
return {
tagName,
classes,
hasStringIcon:
typeof classes === 'string' &&
(classes.includes('pi ') || classes.includes('mdi '))
}
})
// Verify it's a component icon (not a string icon with pi/mdi classes)
expect(menuIconType.hasStringIcon).toBe(false)
// Close menu
await comfyPage.page.locator('body').click({ position: { x: 10, y: 10 } })
// Now check the sidebar templates button
const sidebarTemplatesButton = comfyPage.page.locator(
'.templates-tab-button'
)
await expect(sidebarTemplatesButton).toBeVisible()
// Get the sidebar icon info
const sidebarIcon = sidebarTemplatesButton.locator(
'.side-bar-button-icon'
)
await expect(sidebarIcon).toBeVisible()
const sidebarIconType = await sidebarIcon.evaluate((el) => {
const tagName = el.tagName.toLowerCase()
const classes = el.className || ''
return {
tagName,
classes,
hasStringIcon:
typeof classes === 'string' &&
(classes.includes('pi ') || classes.includes('mdi '))
}
})
// Verify sidebar also uses component icon (not string icon)
expect(sidebarIconType.hasStringIcon).toBe(false)
// Both should be using the same custom component (likely SVG elements)
expect(menuIconType.tagName).toBe('svg')
expect(sidebarIconType.tagName).toBe('svg')
})
})
// Only test 'Top' to reduce test time.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.9",
"version": "1.26.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.25.9",
"version": "1.26.4",
"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.9",
"version": "1.26.4",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -10,8 +10,7 @@
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
'p-breadcrumb-item-link-icon-visible': isActive,
'active-breadcrumb-item': isActive
'p-breadcrumb-item-link-icon-visible': isActive
}"
@click="handleClick"
>
@@ -112,7 +111,21 @@ const menuItems = computed<MenuItem[]>(() => {
{
label: t('g.rename'),
icon: 'pi pi-pencil',
command: startRename
command: async () => {
let initialName =
workflowStore.activeSubgraph?.name ??
workflowStore.activeWorkflow?.filename
if (!initialName) return
const newName = await dialogService.prompt({
title: t('g.rename'),
message: t('breadcrumbsMenu.enterNewName'),
defaultValue: initialName
})
await rename(newName, initialName)
}
},
{
label: t('breadcrumbsMenu.duplicate'),
@@ -162,22 +175,18 @@ const handleClick = (event: MouseEvent) => {
menu.value?.hide()
event.stopPropagation()
event.preventDefault()
startRename()
}
}
const startRename = () => {
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
isEditing.value = true
itemLabel.value = props.item.label as string
void nextTick(() => {
if (itemInputRef.value?.$el) {
itemInputRef.value.$el.focus()
itemInputRef.value.$el.select()
if (wrapperRef.value) {
itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
}
}
}
})
})
}
}
const inputBlur = async (doRename: boolean) => {
@@ -203,8 +212,4 @@ const inputBlur = async (doRename: boolean) => {
.p-breadcrumb-item-label {
@apply whitespace-nowrap text-ellipsis overflow-hidden;
}
.active-breadcrumb-item {
color: var(--text-primary);
}
</style>

View File

@@ -21,6 +21,7 @@
<MiniMap
v-if="comfyAppReady && minimapEnabled"
ref="minimapRef"
class="pointer-events-auto"
/>
</template>
@@ -70,6 +71,7 @@ import { useContextMenuTranslation } from '@/composables/useContextMenuTranslati
import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
import { useMinimap } from '@/composables/useMinimap'
import { usePaste } from '@/composables/usePaste'
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
@@ -117,7 +119,9 @@ const selectionToolboxEnabled = computed(() =>
settingStore.get('Comfy.Canvas.SelectionToolbox')
)
const minimapRef = ref<InstanceType<typeof MiniMap>>()
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const minimap = useMinimap()
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
@@ -354,6 +358,13 @@ onMounted(async () => {
}
)
whenever(
() => minimapRef.value,
(ref) => {
minimap.setMinimapRef(ref)
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {

View File

@@ -1,7 +1,6 @@
<template>
<div
v-if="visible && initialized"
ref="minimapRef"
class="minimap-main-container flex absolute bottom-[20px] right-[90px] z-[1000]"
>
<MiniMapPanel
@@ -55,13 +54,15 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useMinimap } from '@/composables/useMinimap'
import { useCanvasStore } from '@/stores/graphStore'
import MiniMapPanel from './MiniMapPanel.vue'
const minimapRef = ref<HTMLDivElement>()
const minimap = useMinimap()
const canvasStore = useCanvasStore()
const {
initialized,
@@ -79,13 +80,13 @@ const {
renderBypass,
renderError,
updateOption,
init,
destroy,
handlePointerDown,
handlePointerMove,
handlePointerUp,
handleWheel,
setMinimapRef
} = useMinimap()
handleWheel
} = minimap
const showOptionsPanel = ref(false)
@@ -93,8 +94,20 @@ const toggleOptionsPanel = () => {
showOptionsPanel.value = !showOptionsPanel.value
}
onMounted(() => {
setMinimapRef(minimapRef.value)
watch(
() => canvasStore.canvas,
async (canvas) => {
if (canvas && !initialized.value) {
await init()
}
},
{ immediate: true }
)
onMounted(async () => {
if (canvasStore.canvas) {
await init()
}
})
onUnmounted(() => {

View File

@@ -7,7 +7,13 @@
}"
severity="secondary"
text
:icon="typeof command.icon === 'function' ? command.icon() : command.icon"
:icon="
typeof command.icon === 'function'
? command.icon()
: typeof command.icon === 'string'
? command.icon
: undefined
"
@click="() => commandStore.execute(command.id)"
/>
</template>

View File

@@ -66,18 +66,20 @@
class="p-menubar-item-icon pi pi-check text-sm"
:class="{ invisible: !item.comfyCommand?.active?.() }"
/>
<span
v-else-if="
item.icon && item.comfyCommand?.id !== 'Comfy.NewBlankWorkflow'
"
<component
:is="getIconComponent(item)"
v-else-if="getIconLocation(item) === 'left'"
class="p-menubar-item-icon"
:class="item.icon"
:class="typeof item.icon === 'string' ? item.icon : undefined"
/>
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<i
v-if="item.comfyCommand?.id === 'Comfy.NewBlankWorkflow'"
<component
:is="getIconComponent(item)"
v-if="getIconLocation(item) === 'right'"
class="ml-auto"
:class="item.icon"
:class="typeof item.icon === 'string' ? item.icon : undefined"
/>
<span
v-if="item?.comfyCommand?.keybinding"
@@ -94,13 +96,14 @@
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import type { MenuItem as PrimeMenuItem } from 'primevue/menuitem'
import SelectButton from 'primevue/selectbutton'
import TieredMenu, {
type TieredMenuMethods,
type TieredMenuState
} from 'primevue/tieredmenu'
import { computed, nextTick, ref } from 'vue'
import { computed, defineAsyncComponent, markRaw, nextTick, ref } from 'vue'
import type { Component } from 'vue'
import { useI18n } from 'vue-i18n'
import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
@@ -117,6 +120,15 @@ import { showNativeSystemMenu } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { whileMouseDown } from '@/utils/mouseDownUtil'
// Extended MenuItem interface that supports component icons
interface MenuItem extends Omit<PrimeMenuItem, 'icon'> {
icon?: string | { component: Component } | undefined
}
const TemplateIcon = markRaw(
defineAsyncComponent(() => import('virtual:icons/comfy/template'))
)
const colorPaletteStore = useColorPaletteStore()
const menuItemsStore = useMenuItemStore()
const commandStore = useCommandStore()
@@ -187,7 +199,7 @@ const extraMenuItems: MenuItem[] = [
{
key: 'browse-templates',
label: t('menuLabels.Browse Templates'),
icon: 'pi pi-folder-open',
icon: { component: TemplateIcon },
command: () => commandStore.execute('Comfy.BrowseTemplates')
},
{
@@ -254,7 +266,8 @@ const translatedItems = computed(() => {
: [])
)
return items
// Cast to PrimeVue type - our template overrides icon handling
return items as PrimeMenuItem[]
})
const onMenuShow = () => {
@@ -303,6 +316,24 @@ const handleItemClick = (item: MenuItem, event: MouseEvent) => {
const hasActiveStateSiblings = (item: MenuItem): boolean => {
return menuItemsStore.menuItemHasActiveStateChildren[item.parentPath]
}
const getIconComponent = (
item: MenuItem | PrimeMenuItem
): string | Component | undefined => {
return typeof item.icon === 'string' ? 'i' : item.icon?.component
}
const getIconLocation = (
item: MenuItem | PrimeMenuItem
): 'left' | 'right' | null => {
if (!item.icon) return null
if (item.comfyCommand?.id === 'Comfy.NewBlankWorkflow') {
return 'right'
}
return 'left'
}
</script>
<style scoped>

View File

@@ -685,10 +685,9 @@ export function useMinimap() {
const originalCallbacks = originalCallbacksMap.get(g.id)
if (!originalCallbacks) {
console.error(
throw new Error(
'Attempted to cleanup event listeners for graph that was never set up'
)
return
}
g.onNodeAdded = originalCallbacks.onNodeAdded

View File

@@ -1574,10 +1574,7 @@ export class LGraphNode
* remove an existing output slot
*/
removeOutput(slot: number): void {
// Only disconnect if node is part of a graph
if (this.graph) {
this.disconnectOutput(slot)
}
this.disconnectOutput(slot)
const { outputs } = this
outputs.splice(slot, 1)
@@ -1585,12 +1582,11 @@ export class LGraphNode
const output = outputs[i]
if (!output || !output.links) continue
// Only update link indices if node is part of a graph
if (this.graph) {
for (const linkId of output.links) {
const link = this.graph._links.get(linkId)
if (link) link.origin_slot--
}
for (const linkId of output.links) {
if (!this.graph) throw new NullGraphError()
const link = this.graph._links.get(linkId)
if (link) link.origin_slot--
}
}
@@ -1630,10 +1626,7 @@ export class LGraphNode
* remove an existing input slot
*/
removeInput(slot: number): void {
// Only disconnect if node is part of a graph
if (this.graph) {
this.disconnectInput(slot, true)
}
this.disconnectInput(slot, true)
const { inputs } = this
const slot_info = inputs.splice(slot, 1)
@@ -1641,11 +1634,9 @@ export class LGraphNode
const input = inputs[i]
if (!input?.link) continue
// Only update link indices if node is part of a graph
if (this.graph) {
const link = this.graph._links.get(input.link)
if (link) link.target_slot--
}
if (!this.graph) throw new NullGraphError()
const link = this.graph._links.get(input.link)
if (link) link.target_slot--
}
this.onInputRemoved?.(slot, slot_info[0])
this.setDirtyCanvas(true, true)

View File

@@ -656,119 +656,4 @@ describe('LGraphNode', () => {
spy.mockRestore()
})
})
describe('removeInput/removeOutput on copied nodes', () => {
beforeEach(() => {
// Register a test node type so clone() can work
LiteGraph.registerNodeType('TestNode', LGraphNode)
})
test('should NOT throw error when calling removeInput on a copied node without graph', () => {
// Create a node with an input
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addInput('input1', 'number')
// Clone the node (which creates a node without graph reference)
const copiedNode = originalNode.clone()
// This should NOT throw anymore - we can remove inputs on nodes without graph
expect(() => copiedNode!.removeInput(0)).not.toThrow()
expect(copiedNode!.inputs).toHaveLength(0)
})
test('should NOT throw error when calling removeOutput on a copied node without graph', () => {
// Create a node with an output
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addOutput('output1', 'number')
// Clone the node (which creates a node without graph reference)
const copiedNode = originalNode.clone()
// This should NOT throw anymore - we can remove outputs on nodes without graph
expect(() => copiedNode!.removeOutput(0)).not.toThrow()
expect(copiedNode!.outputs).toHaveLength(0)
})
test('should skip disconnectInput/disconnectOutput when node has no graph', () => {
// Create nodes with input/output
const nodeWithInput = new LGraphNode('Test Node')
nodeWithInput.type = 'TestNode'
nodeWithInput.addInput('input1', 'number')
const nodeWithOutput = new LGraphNode('Test Node')
nodeWithOutput.type = 'TestNode'
nodeWithOutput.addOutput('output1', 'number')
// Clone nodes (no graph reference)
const clonedInput = nodeWithInput.clone()
const clonedOutput = nodeWithOutput.clone()
// Mock disconnect methods to verify they're not called
clonedInput!.disconnectInput = vi.fn()
clonedOutput!.disconnectOutput = vi.fn()
// Remove input/output - disconnect methods should NOT be called
clonedInput!.removeInput(0)
clonedOutput!.removeOutput(0)
expect(clonedInput!.disconnectInput).not.toHaveBeenCalled()
expect(clonedOutput!.disconnectOutput).not.toHaveBeenCalled()
})
test('should be able to removeInput on a copied node after adding to graph', () => {
// Create a graph and a node with an input
const graph = new LGraph()
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addInput('input1', 'number')
// Clone the node and add to graph
const copiedNode = originalNode.clone()
expect(copiedNode).not.toBeNull()
graph.add(copiedNode!)
// This should work now that the node has a graph reference
expect(() => copiedNode!.removeInput(0)).not.toThrow()
expect(copiedNode!.inputs).toHaveLength(0)
})
test('should be able to removeOutput on a copied node after adding to graph', () => {
// Create a graph and a node with an output
const graph = new LGraph()
const originalNode = new LGraphNode('Test Node')
originalNode.type = 'TestNode'
originalNode.addOutput('output1', 'number')
// Clone the node and add to graph
const copiedNode = originalNode.clone()
expect(copiedNode).not.toBeNull()
graph.add(copiedNode!)
// This should work now that the node has a graph reference
expect(() => copiedNode!.removeOutput(0)).not.toThrow()
expect(copiedNode!.outputs).toHaveLength(0)
})
test('RerouteNode clone scenario - should be able to removeOutput and addOutput on cloned node', () => {
// This simulates the RerouteNode clone method behavior
const originalNode = new LGraphNode('Reroute')
originalNode.type = 'TestNode'
originalNode.addOutput('*', '*')
// Clone the node (simulating RerouteNode.clone)
const clonedNode = originalNode.clone()
expect(clonedNode).not.toBeNull()
// This should not throw - we should be able to modify outputs on a cloned node
expect(() => {
clonedNode!.removeOutput(0)
clonedNode!.addOutput('renamed', '*')
}).not.toThrow()
expect(clonedNode!.outputs).toHaveLength(1)
expect(clonedNode!.outputs[0].name).toBe('renamed')
})
})
})

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { Component } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { ComfyExtension } from '@/types/comfy'
@@ -11,7 +12,7 @@ export interface ComfyCommand {
function: () => void | Promise<void>
label?: string | (() => string)
icon?: string | (() => string)
icon?: string | { component: Component } | (() => string)
tooltip?: string | (() => string)
menubarLabel?: string | (() => string) // Menubar item label, if different from command label
versionAdded?: string
@@ -25,7 +26,7 @@ export class ComfyCommandImpl implements ComfyCommand {
id: string
function: () => void | Promise<void>
_label?: string | (() => string)
_icon?: string | (() => string)
_icon?: string | { component: Component } | (() => string)
_tooltip?: string | (() => string)
_menubarLabel?: string | (() => string)
versionAdded?: string

View File

@@ -57,7 +57,10 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
useCommandStore().registerCommand({
id: `Workspace.ToggleSidebarTab.${tab.id}`,
icon: typeof tab.icon === 'string' ? tab.icon : undefined,
icon:
!tab.icon || typeof tab.icon === 'string'
? tab.icon
: { component: tab.icon },
label: labelFunction,
menubarLabel: menubarLabelFunction,
tooltip: tooltipFunction,

View File

@@ -27,41 +27,25 @@ export class ExecutableGroupNodeChildDTO extends ExecutableNodeDTO {
}
override resolveInput(slot: number) {
// Check if this group node is inside a subgraph (unsupported)
if (this.id.split(':').length > 2) {
throw new Error(
'Group nodes inside subgraphs are not supported. Please convert the group node to a subgraph instead.'
)
}
const inputNode = this.node.getInputNode(slot)
if (!inputNode) return
const link = this.node.getInputLink(slot)
if (!link) throw new Error('Failed to get input link')
const inputNodeId = String(inputNode.id)
// Try to find the node using the full ID first (for nodes outside the group)
let inputNodeDto = this.nodesByExecutionId?.get(inputNodeId)
// If not found, try with just the last part of the ID (for nodes inside the group)
if (!inputNodeDto) {
const id = inputNodeId.split(':').at(-1)
if (id !== undefined) {
inputNodeDto = this.nodesByExecutionId?.get(id)
}
}
const id = String(inputNode.id).split(':').at(-1)
if (id === undefined) throw new Error('Invalid input node id')
const inputNodeDto = this.nodesByExecutionId?.get(id)
if (!inputNodeDto) {
throw new Error(
`Failed to get input node ${inputNodeId} for group node child ${this.id} with slot ${slot}`
`Failed to get input node ${id} for group node child ${this.id} with slot ${slot}`
)
}
return {
node: inputNodeDto,
origin_id: inputNodeId,
origin_id: String(inputNode.id),
origin_slot: link.origin_slot
}
}

View File

@@ -1,199 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GroupNodeHandler } from '@/extensions/core/groupNode'
import type {
ExecutableLGraphNode,
ExecutionId,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { ExecutableGroupNodeChildDTO } from '@/utils/executableGroupNodeChildDTO'
describe('ExecutableGroupNodeChildDTO', () => {
let mockNode: LGraphNode
let mockInputNode: LGraphNode
let mockNodesByExecutionId: Map<ExecutionId, ExecutableLGraphNode>
let mockGroupNodeHandler: GroupNodeHandler
beforeEach(() => {
// Create mock nodes
mockNode = {
id: '3', // Simple node ID for most tests
graph: {},
getInputNode: vi.fn(),
getInputLink: vi.fn(),
inputs: []
} as any
mockInputNode = {
id: '1',
graph: {}
} as any
// Create the nodesByExecutionId map
mockNodesByExecutionId = new Map()
mockGroupNodeHandler = {} as GroupNodeHandler
})
describe('resolveInput', () => {
it('should resolve input from external node (node outside the group)', () => {
// Setup: Group node child with ID '10:3'
const groupNodeChild = {
id: '10:3',
graph: {},
getInputNode: vi.fn().mockReturnValue(mockInputNode),
getInputLink: vi.fn().mockReturnValue({
origin_slot: 0
}),
inputs: []
} as any
// External node with ID '1'
const externalNodeDto = {
id: '1',
type: 'TestNode'
} as ExecutableLGraphNode
mockNodesByExecutionId.set('1', externalNodeDto)
const dto = new ExecutableGroupNodeChildDTO(
groupNodeChild,
[], // No subgraph path - group is in root graph
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
const result = dto.resolveInput(0)
expect(result).toEqual({
node: externalNodeDto,
origin_id: '1',
origin_slot: 0
})
})
it('should resolve input from internal node (node inside the same group)', () => {
// Setup: Group node child with ID '10:3'
const groupNodeChild = {
id: '10:3',
graph: {},
getInputNode: vi.fn(),
getInputLink: vi.fn(),
inputs: []
} as any
// Internal node with ID '10:2'
const internalInputNode = {
id: '10:2',
graph: {}
} as LGraphNode
const internalNodeDto = {
id: '2',
type: 'InternalNode'
} as ExecutableLGraphNode
// Internal nodes are stored with just their index
mockNodesByExecutionId.set('2', internalNodeDto)
groupNodeChild.getInputNode.mockReturnValue(internalInputNode)
groupNodeChild.getInputLink.mockReturnValue({
origin_slot: 1
})
const dto = new ExecutableGroupNodeChildDTO(
groupNodeChild,
[],
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
const result = dto.resolveInput(0)
expect(result).toEqual({
node: internalNodeDto,
origin_id: '10:2',
origin_slot: 1
})
})
it('should return undefined if no input node exists', () => {
mockNode.getInputNode = vi.fn().mockReturnValue(null)
const dto = new ExecutableGroupNodeChildDTO(
mockNode,
[],
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
const result = dto.resolveInput(0)
expect(result).toBeUndefined()
})
it('should throw error if input link is missing', () => {
mockNode.getInputNode = vi.fn().mockReturnValue(mockInputNode)
mockNode.getInputLink = vi.fn().mockReturnValue(null)
const dto = new ExecutableGroupNodeChildDTO(
mockNode,
[],
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
expect(() => dto.resolveInput(0)).toThrow('Failed to get input link')
})
it('should throw error if input node cannot be found in nodesByExecutionId', () => {
// Node exists but is not in the map
mockNode.getInputNode = vi.fn().mockReturnValue(mockInputNode)
mockNode.getInputLink = vi.fn().mockReturnValue({
origin_slot: 0
})
const dto = new ExecutableGroupNodeChildDTO(
mockNode,
[],
mockNodesByExecutionId, // Empty map
undefined,
mockGroupNodeHandler
)
expect(() => dto.resolveInput(0)).toThrow(
'Failed to get input node 1 for group node child 3 with slot 0'
)
})
it('should throw error for group nodes inside subgraphs (unsupported)', () => {
// Setup: Group node child inside a subgraph (execution ID has more than 2 segments)
const nestedGroupNode = {
id: '1:2:3', // subgraph:groupnode:innernode
graph: {},
getInputNode: vi.fn().mockReturnValue(mockInputNode),
getInputLink: vi.fn().mockReturnValue({
origin_slot: 0
}),
inputs: []
} as any
// Create DTO with deeply nested path to simulate group node inside subgraph
const dto = new ExecutableGroupNodeChildDTO(
nestedGroupNode,
['1', '2'], // Path indicating it's inside a subgraph then group
mockNodesByExecutionId,
undefined,
mockGroupNodeHandler
)
expect(() => dto.resolveInput(0)).toThrow(
'Group nodes inside subgraphs are not supported. Please convert the group node to a subgraph instead.'
)
})
})
})