mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
1 Commits
version-bu
...
pysssss/me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e07fb481 |
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user