Compare commits

..

1 Commits

Author SHA1 Message Date
huang47
9d4cc41dfc refactor: tidy executionStore job-state handling 2026-06-30 22:38:05 -07:00
11 changed files with 459 additions and 1816 deletions

View File

@@ -34,22 +34,17 @@ describe('useSelectionToolboxPosition', () => {
canvasStore = useCanvasStore()
})
function renderToolboxForSelection(
items: Iterable<Positionable>,
state: Partial<LGraphCanvas['state']> = {},
ds: Partial<LGraphCanvas['ds']> = {}
) {
function renderToolboxForSelection(item: Positionable) {
canvasStore.canvas = markRaw({
canvas: document.createElement('canvas'),
ds: {
offset: ds.offset ?? [0, 0],
scale: ds.scale ?? 1
offset: [0, 0],
scale: 1
},
selectedItems: new Set(items),
selectedItems: new Set([item]),
state: {
draggingItems: false,
selectionChanged: true,
...state
selectionChanged: true
}
} as Partial<LGraphCanvas> as LGraphCanvas)
@@ -74,7 +69,7 @@ describe('useSelectionToolboxPosition', () => {
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group])
const { toolbox, unmount } = renderToolboxForSelection(group)
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
@@ -86,64 +81,11 @@ describe('useSelectionToolboxPosition', () => {
node.pos = [100, 200]
node.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([node])
const { toolbox, unmount } = renderToolboxForSelection(node)
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
it('does not set coordinates when selection is empty', () => {
const { toolbox, unmount } = renderToolboxForSelection([])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('does not set coordinates while selected items are being dragged', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group], {
draggingItems: true
})
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('positions multiple selected items from their union bounds', () => {
const first = new LGraphGroup('First', 1)
first.pos = [100, 200]
first.size = [100, 40]
const second = new LGraphGroup('Second', 2)
second.pos = [300, 260]
second.size = [50, 40]
const { toolbox, unmount } = renderToolboxForSelection([first, second])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('270px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
})
it('applies canvas scale and offset to screen coordinates', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [100, 40]
const { toolbox, unmount } = renderToolboxForSelection(
[group],
{},
{ offset: [10, 20], scale: 2 }
)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('360px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('420px')
unmount()
})
})

View File

@@ -1,7 +1,6 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { useImageMenuOptions } from './useImageMenuOptions'
@@ -20,11 +19,6 @@ vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: vi.fn() })
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn(),
openFileInNewTab: vi.fn()
}))
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
Object.defineProperty(navigator, 'clipboard', {
value: clipboard,
@@ -33,15 +27,6 @@ function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
})
}
function stubClipboardItem() {
vi.stubGlobal(
'ClipboardItem',
class ClipboardItemStub {
constructor(public readonly items: Record<string, Blob>) {}
}
)
}
function createImageNode(
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
): LGraphNode {
@@ -60,13 +45,8 @@ function createImageNode(
}
describe('useImageMenuOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('getImageMenuOptions', () => {
@@ -202,147 +182,4 @@ describe('useImageMenuOptions', () => {
expect(node.pasteFiles).not.toHaveBeenCalled()
})
})
describe('image actions', () => {
it('opens the selected image without preview query params', () => {
const node = createImageNode()
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
const { getImageMenuOptions } = useImageMenuOptions()
const openOption = getImageMenuOptions(node).find(
(o) => o.label === 'Open Image'
)
openOption?.action?.()
expect(openFileInNewTab).toHaveBeenCalledWith(
'http://localhost/test.png?foo=bar'
)
})
it('saves the selected image without preview query params', () => {
const node = createImageNode()
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
const { getImageMenuOptions } = useImageMenuOptions()
const saveOption = getImageMenuOptions(node).find(
(o) => o.label === 'Save Image'
)
saveOption?.action?.()
expect(downloadFile).toHaveBeenCalledWith(
'http://localhost/test.png?foo=bar'
)
})
it('does not open or save when the active image is missing', () => {
const node = createImageNode({ imageIndex: 1 })
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
const openOption = options.find((o) => o.label === 'Open Image')
const saveOption = options.find((o) => o.label === 'Save Image')
expect(openOption?.action).toEqual(expect.any(Function))
expect(saveOption?.action).toEqual(expect.any(Function))
openOption?.action?.()
saveOption?.action?.()
expect(openFileInNewTab).not.toHaveBeenCalled()
expect(downloadFile).not.toHaveBeenCalled()
})
it('logs save failures for invalid image URLs', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = createImageNode()
Object.defineProperty(node.imgs![0], 'src', {
value: 'http://[',
configurable: true
})
const { getImageMenuOptions } = useImageMenuOptions()
getImageMenuOptions(node)
.find((o) => o.label === 'Save Image')
?.action?.()
expect(errorSpy).toHaveBeenCalledWith(
'Failed to save image:',
expect.any(TypeError)
)
expect(downloadFile).not.toHaveBeenCalled()
})
it('copies the selected image to clipboard', async () => {
const node = createImageNode()
const drawImage = vi.fn()
const write = vi.fn().mockResolvedValue(undefined)
stubClipboardItem()
mockClipboard(fromPartial<Clipboard>({ write }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(drawImage).toHaveBeenCalledWith(node.imgs![0], 0, 0)
expect(write).toHaveBeenCalledWith([
expect.objectContaining({
items: { 'image/png': expect.any(Blob) }
})
])
})
it('does not copy when canvas context is unavailable', async () => {
const node = createImageNode()
const write = vi.fn()
mockClipboard(fromPartial<Clipboard>({ write }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
(() => null) as HTMLCanvasElement['getContext']
)
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(write).not.toHaveBeenCalled()
})
it('does not copy when canvas blob creation fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node = createImageNode()
const write = vi.fn()
mockClipboard(fromPartial<Clipboard>({ write }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn()
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(null)
}
)
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(warnSpy).toHaveBeenCalledWith('Failed to create image blob')
expect(write).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,315 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick } from 'vue'
import type { App as VueApp } from 'vue'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { BadgePosition, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { ComfyExtension } from '@/types/comfy'
import { toNodeId } from '@/types/nodeId'
import { NodeBadgeMode } from '@/types/nodeSource'
const {
settings,
appState,
extensionState,
nodeDefState,
pricingState,
setDirtyMock,
addEventListenerMock,
registerExtensionMock,
getCreditsBadgeMock,
updateSubgraphCreditsMock,
getNodePricingConfigMock,
getNodeDisplayPriceMock,
getRelevantWidgetNamesMock,
triggerPriceRecalculationMock,
useComputedWithWidgetWatchMock
} = vi.hoisted(() => ({
settings: {} as Record<string, unknown>,
appState: {
graph: {
nodes: [] as unknown[]
}
},
extensionState: {
installed: false,
registered: undefined as ComfyExtension | undefined
},
nodeDefState: {
value: null as Record<string, unknown> | null
},
pricingState: {
revision: { value: 0 },
config: undefined as
| {
depends_on?: {
widgets?: string[]
inputs?: string[]
input_groups?: string[]
}
}
| undefined,
label: '1 credit'
},
setDirtyMock: vi.fn(),
addEventListenerMock: vi.fn(),
registerExtensionMock: vi.fn((extension: ComfyExtension) => {
extensionState.registered = extension
}),
getCreditsBadgeMock: vi.fn((text: string) => ({ text })),
updateSubgraphCreditsMock: vi.fn(),
getNodePricingConfigMock: vi.fn(() => pricingState.config),
getNodeDisplayPriceMock: vi.fn(() => pricingState.label),
getRelevantWidgetNamesMock: vi.fn(() => ['seed']),
triggerPriceRecalculationMock: vi.fn(),
useComputedWithWidgetWatchMock: vi.fn(() => vi.fn())
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
setDirty: setDirtyMock,
canvas: {
addEventListener: addEventListenerMock
},
graph: appState.graph
}
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => settings[key]
})
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => ({
isExtensionInstalled: () => extensionState.installed,
registerExtension: registerExtensionMock
})
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
fromLGraphNode: () => nodeDefState.value
})
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
colors: {
litegraph_base: {
BADGE_FG_COLOR: '#fff',
BADGE_BG_COLOR: '#000'
}
}
}
})
}))
vi.mock('@/composables/node/useNodePricing', () => ({
useNodePricing: () => ({
pricingRevision: pricingState.revision,
getNodePricingConfig: getNodePricingConfigMock,
getNodeDisplayPrice: getNodeDisplayPriceMock,
getRelevantWidgetNames: getRelevantWidgetNamesMock,
triggerPriceRecalculation: triggerPriceRecalculationMock
})
}))
vi.mock('@/composables/node/usePriceBadge', () => ({
usePriceBadge: () => ({
getCreditsBadge: getCreditsBadgeMock,
updateSubgraphCredits: updateSubgraphCreditsMock
})
}))
vi.mock('@/composables/node/useWatchWidget', () => ({
useComputedWithWidgetWatch: useComputedWithWidgetWatchMock
}))
class ApiNode extends LGraphNode {
static override nodeData = { name: 'ApiNode', api_node: true }
}
function mountBadge(): VueApp {
const app = createApp(
defineComponent({
setup() {
useNodeBadge()
return () => h('div')
}
})
)
app.mount(document.createElement('div'))
return app
}
function registeredExtension(): ComfyExtension {
if (!extensionState.registered)
throw new Error('Missing registered extension')
return extensionState.registered
}
function comfyApp(): Parameters<NonNullable<ComfyExtension['init']>>[0] {
return {} as Parameters<NonNullable<ComfyExtension['init']>>[0]
}
function callNodeCreated(node: LGraphNode) {
registeredExtension().nodeCreated?.(node, comfyApp())
}
function inputSlot(name: string) {
return new LGraphNode('slot').addInput(name, '*')
}
function defaultSettings() {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.ShowApiPricing'] = false
}
describe('useNodeBadge', () => {
let mountedApp: VueApp | undefined
beforeEach(() => {
defaultSettings()
extensionState.installed = false
extensionState.registered = undefined
appState.graph.nodes = []
nodeDefState.value = null
pricingState.revision.value = 0
pricingState.config = undefined
pricingState.label = '1 credit'
setDirtyMock.mockClear()
addEventListenerMock.mockClear()
registerExtensionMock.mockClear()
getCreditsBadgeMock.mockClear()
updateSubgraphCreditsMock.mockClear()
getNodePricingConfigMock.mockClear()
getNodeDisplayPriceMock.mockClear()
getRelevantWidgetNamesMock.mockClear()
triggerPriceRecalculationMock.mockClear()
useComputedWithWidgetWatchMock.mockClear()
})
afterEach(() => {
mountedApp?.unmount()
mountedApp = undefined
})
it('does not register the badge extension twice', async () => {
extensionState.installed = true
mountedApp = mountBadge()
await nextTick()
expect(registerExtensionMock).not.toHaveBeenCalled()
})
it('adds the configured node identity badge', async () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
NodeBadgeMode.HideBuiltIn
nodeDefState.value = {
isCoreNode: false,
nodeLifeCycleBadgeText: 'Beta',
nodeSource: { badgeText: 'Pack' }
}
const node = new LGraphNode('Test')
node.id = toNodeId('7')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
const badge = node.badges[0] as () => LGraphBadge
expect(node.badgePosition).toBe(BadgePosition.TopRight)
expect(badge().text).toBe('#7 Beta Pack')
})
it('hides built-in badge text when the mode excludes core nodes', async () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.HideBuiltIn
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
NodeBadgeMode.HideBuiltIn
nodeDefState.value = {
isCoreNode: true,
nodeLifeCycleBadgeText: 'Core',
nodeSource: { badgeText: 'Built-in' }
}
const node = new LGraphNode('Core')
node.id = toNodeId('11')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
const badge = node.badges[0] as () => LGraphBadge
expect(badge().text).toBe('#11')
})
it('adds dynamic API pricing badges and refreshes relevant input changes', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = true
pricingState.config = {
depends_on: {
widgets: ['seed'],
inputs: ['image'],
input_groups: ['lora']
}
}
const originalOnConnectionsChange = vi.fn()
const node = new ApiNode('API')
node.onConnectionsChange = originalOnConnectionsChange
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
expect(useComputedWithWidgetWatchMock).toHaveBeenCalledWith(node, {
widgetNames: ['seed'],
triggerCanvasRedraw: true
})
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
const priceBadge = node.badges[1] as () => { text: string }
expect(priceBadge().text).toBe('1 credit')
pricingState.label = '2 credits'
expect(priceBadge().text).toBe('2 credits')
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('lora.0'))
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('clip'))
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot(''))
expect(originalOnConnectionsChange).toHaveBeenCalledTimes(4)
expect(triggerPriceRecalculationMock).toHaveBeenCalledTimes(2)
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
})
it('updates subgraph credit badges from registered extension hooks', async () => {
const nodes = [new LGraphNode('one'), new LGraphNode('two')]
appState.graph.nodes = nodes
mountedApp = mountBadge()
await nextTick()
await registeredExtension().init?.(comfyApp())
await registeredExtension().afterConfigureGraph?.([], comfyApp())
const setGraphHandler = addEventListenerMock.mock.calls.find(
([event]) => event === 'litegraph:set-graph'
)?.[1]
const convertedHandler = addEventListenerMock.mock.calls.find(
([event]) => event === 'subgraph-converted'
)?.[1]
setGraphHandler?.()
convertedHandler?.({ detail: { subgraphNode: nodes[0] } })
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[0])
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[1])
})
})

View File

@@ -1,102 +0,0 @@
import { ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import type { TreeNode } from '@/types/treeExplorerTypes'
function node(over: Partial<TreeNode>): TreeNode {
return over as TreeNode
}
// root ─┬─ a ── a1 (leaf)
// └─ b (leaf)
function sampleTree() {
const a1 = node({ key: 'a1', leaf: true })
const a = node({ key: 'a', leaf: false, children: [a1] })
const b = node({ key: 'b', leaf: true })
const root = node({ key: 'root', leaf: false, children: [a, b] })
return { root, a, a1, b }
}
describe('useTreeExpansion', () => {
it('toggleNode adds then removes a node key', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNode } = useTreeExpansion(expandedKeys)
const n = node({ key: 'x' })
toggleNode(n)
expect(expandedKeys.value).toEqual({ x: true })
toggleNode(n)
expect(expandedKeys.value).toEqual({})
})
it('toggleNode ignores nodes without a string key', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNode } = useTreeExpansion(expandedKeys)
toggleNode(node({ key: undefined }))
toggleNode(node({ key: 42 as unknown as string }))
expect(expandedKeys.value).toEqual({})
})
it('expandNode expands the node and all non-leaf descendants only', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
expandNode(root)
// root and a are folders; a1 and b are leaves and must be skipped
expect(expandedKeys.value).toEqual({ root: true, a: true })
})
it('expandNode does nothing for a leaf node', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode } = useTreeExpansion(expandedKeys)
expandNode(node({ key: 'leaf', leaf: true }))
expect(expandedKeys.value).toEqual({})
})
it('collapseNode removes the node and its non-leaf descendants', () => {
const expandedKeys = ref<Record<string, boolean>>({
root: true,
a: true,
stray: true
})
const { collapseNode } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
collapseNode(root)
expect(expandedKeys.value).toEqual({ stray: true })
})
it('toggleNodeRecursive expands when collapsed and collapses when expanded', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNodeRecursive } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
toggleNodeRecursive(root)
expect(expandedKeys.value).toEqual({ root: true, a: true })
toggleNodeRecursive(root)
expect(expandedKeys.value).toEqual({})
})
it('toggleNodeOnEvent toggles recursively with ctrl and singly without', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
toggleNodeOnEvent(new KeyboardEvent('keydown', { ctrlKey: true }), root)
expect(expandedKeys.value).toEqual({ root: true, a: true })
// Plain toggle removes only the node's own key, leaving descendants
toggleNodeOnEvent(new MouseEvent('click'), root)
expect(expandedKeys.value).toEqual({ a: true })
})
})

View File

@@ -1,47 +1,14 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyApp } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { ComfyExtension } from '@/types/comfy'
import type { GroupNodeWorkflowData } from './groupNode'
const appMock = vi.hoisted(() => ({
canvas: {
emitAfterChange: vi.fn(),
emitBeforeChange: vi.fn(),
selected_nodes: {}
},
registerExtension: vi.fn(),
registerNodeDef: vi.fn(),
rootGraph: {
convertToSubgraph: vi.fn(),
extra: {},
getNodeById: vi.fn(),
links: {},
nodes: [],
remove: vi.fn()
}
}))
const widgetStoreMock = vi.hoisted(() => ({
inputIsWidget: vi.fn((spec: unknown[]) =>
['BOOLEAN', 'COMBO', 'FLOAT', 'INT', 'STRING'].includes(String(spec[0]))
)
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => widgetStoreMock
app: {
registerExtension: vi.fn()
}
}))
import { GroupNodeConfig, replaceLegacySeparators } from './groupNode'
@@ -59,42 +26,6 @@ function makeNode(type: string): ComfyNode {
}
}
function makeNodeDef(overrides: Partial<ComfyNodeDef> = {}): ComfyNodeDef {
return {
name: 'TestNode',
display_name: 'Test Node',
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'test',
...overrides
} as ComfyNodeDef
}
function extension(): ComfyExtension {
const groupExtension = appMock.registerExtension.mock.calls.find(
([registered]) => registered.name === 'Comfy.GroupNode'
)?.[0]
if (!groupExtension) throw new Error('GroupNode extension was not registered')
return groupExtension as ComfyExtension
}
function addCustomNodeDefs(defs: Record<string, ComfyNodeDef>) {
extension().addCustomNodeDefs?.(defs, appMock as unknown as ComfyApp)
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
appMock.registerNodeDef.mockReset()
widgetStoreMock.inputIsWidget.mockClear()
LiteGraph.registered_node_types = {}
addCustomNodeDefs({})
})
describe('replaceLegacySeparators', () => {
it('rewrites the legacy "workflow/" prefix to "workflow>"', () => {
const nodes = [makeNode('workflow/My Group')]
@@ -173,398 +104,4 @@ describe('GroupNodeConfig.getLinks', () => {
const config = configFrom([], [[0, 1, 'IMAGE']])
expect(config.externalFrom[0][1]).toBe('IMAGE')
})
it('ignores external links without a type and accumulates multiple slots', () => {
const config = configFrom(
[],
[
[0, 1, null as unknown as string],
[0, 2, 'LATENT'],
[0, 3, 'IMAGE']
]
)
expect(config.externalFrom[0]).toEqual({ 2: 'LATENT', 3: 'IMAGE' })
})
})
describe('GroupNodeConfig.getNodeDef', () => {
const imageNodeDef = makeNodeDef({
name: 'ImageNode',
input: {
required: {
image: ['IMAGE', {}],
mode: [['fast', 'slow'], {}]
},
optional: {
strength: ['FLOAT', { default: 1 }]
}
},
output: ['IMAGE'],
output_name: ['image'],
output_is_list: [false]
})
beforeEach(() => {
addCustomNodeDefs({ ImageNode: imageNodeDef })
})
it('returns registered definitions for normal node types', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ index: 0, type: 'ImageNode' }],
links: [],
external: []
})
expect(config.getNodeDef({ index: 0, type: 'ImageNode' })).toBe(
imageNodeDef
)
})
it('returns undefined for nodes without an index or a known type', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ type: 'UnknownNode' }],
links: [],
external: []
})
expect(config.getNodeDef({ type: 'UnknownNode' })).toBeUndefined()
})
it('skips unlinked primitive nodes', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ index: 0, type: 'PrimitiveNode' }],
links: [],
external: []
})
expect(
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
).toBeUndefined()
})
it('derives primitive node type from the outgoing link type', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'PrimitiveNode' },
{ index: 1, type: 'ImageNode' }
],
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
external: []
})
expect(
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
).toMatchObject({
input: { required: { value: ['IMAGE', {}] } },
output: ['IMAGE']
})
})
it('falls back to null when primitive combo target spec is not primitive', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{
index: 0,
type: 'PrimitiveNode',
outputs: [{ name: 'mode', widget: { name: 'mode' } }]
},
{ index: 1, type: 'ImageNode' }
],
links: [[0, 0, 1, 0, 1, 'COMBO'] as SerialisedLLinkArray],
external: []
})
expect(config.getNodeDef(config.nodeData.nodes[0])).toMatchObject({
input: { required: { value: [null, {}] } },
output: [null]
})
})
it('returns null for reroutes used only inside the group', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'ImageNode' },
{ index: 1, type: 'Reroute' },
{ index: 2, type: 'ImageNode' }
],
links: [
[0, 0, 1, 0, 1, 'IMAGE'],
[1, 0, 2, 0, 2, 'IMAGE']
] as SerialisedLLinkArray[],
external: []
})
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toBeNull()
})
it('derives reroute type from outgoing target inputs', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'Reroute' },
{
index: 1,
type: 'ImageNode',
inputs: [{ name: 'image', type: 'IMAGE' }]
}
],
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
external: [[0, 0, 'IMAGE']]
})
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
input: { required: { IMAGE: ['IMAGE', { forceInput: true }] } },
output: ['IMAGE']
})
})
it('derives reroute type from incoming output metadata', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'ImageNode', outputs: [{ type: 'LATENT' }] },
{ index: 1, type: 'Reroute' }
],
links: [[0, 0, 1, 0, 1, 'LATENT'] as SerialisedLLinkArray],
external: [[1, 0, 'LATENT']]
})
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toMatchObject({
input: { required: { LATENT: ['LATENT', { forceInput: true }] } },
output: ['LATENT']
})
})
it('derives pipe reroute type from external metadata when links omit it', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ index: 0, type: 'Reroute' }],
links: [],
external: [[0, 0, 'MASK']]
})
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
input: { required: { MASK: ['MASK', { forceInput: true }] } },
output: ['MASK']
})
})
})
describe('GroupNodeConfig input and output mapping', () => {
function configWithNode(node: GroupNodeWorkflowData['nodes'][number]) {
const config = new GroupNodeConfig('group', {
nodes: [node],
links: [],
external: [],
config: {
0: {
input: {
hidden: { visible: false },
renamed: { name: 'Custom Name' }
},
output: {
1: { name: 'Custom Output' },
2: { visible: false }
}
}
}
})
config.nodeDef = makeNodeDef({
input: { required: {} },
output: [],
output_name: [],
output_is_list: []
})
return config
}
it('renames duplicate inputs and adds seed control metadata', () => {
const config = configWithNode({
index: 0,
type: 'Sampler',
title: 'Sampler A',
inputs: [{ name: 'seed', label: 'Seed Label' }]
})
const seenInputs = { seed: 1, 'Sampler A seed': 1 }
const result = config.getInputConfig(
{ index: 0, type: 'Sampler', title: 'Sampler A' },
'seed',
seenInputs,
['INT', {}]
)
expect(result.name).toBe('Sampler A 1 seed')
expect(result.config).toEqual([
'INT',
{ control_after_generate: 'Sampler A control_after_generate' }
])
})
it('maps image upload widget aliases through converted widget names', () => {
const config = configWithNode({ index: 0, type: 'LoadImage' })
config.oldToNewWidgetMap[0] = { customImage: 'Uploaded Image' }
expect(
config.getInputConfig({ index: 0, type: 'LoadImage' }, 'renamed', {}, [
'IMAGEUPLOAD',
{ widget: 'customImage' }
])
).toMatchObject({
name: 'Custom Name',
config: ['IMAGEUPLOAD', { widget: 'Uploaded Image' }]
})
})
it('splits widget inputs, socket inputs, and converted widget slots', () => {
const config = configWithNode({
index: 0,
type: 'MixedNode',
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
})
const result = config.processWidgetInputs(
{
mode: ['COMBO', {}],
image: ['IMAGE', {}]
},
{
index: 0,
type: 'MixedNode',
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
},
['mode', 'image'],
{}
)
expect(result.slots).toEqual(['image'])
expect(result.converted.get(0)).toBe('mode')
expect(config.oldToNewWidgetMap[0].mode).toBeNull()
})
it('adds visible unlinked input slots and skips hidden configured inputs', () => {
const config = configWithNode({
index: 0,
type: 'InputNode'
})
const inputMap: Record<number, number> = {}
config.processInputSlots(
{
image: ['IMAGE', {}],
hidden: ['LATENT', {}]
},
{ index: 0, type: 'InputNode' },
['image', 'hidden'],
{},
inputMap,
{}
)
expect(config.nodeDef?.input?.required).toEqual({ image: ['IMAGE', {}] })
expect(inputMap).toEqual({ 0: 0 })
})
it('adds output metadata, hides linked/internal outputs, and dedupes labels', () => {
const config = configWithNode({
index: 0,
type: 'OutputNode',
title: 'Output A',
outputs: [{ name: 'image', label: 'Rendered' }]
})
config.linksFrom[0] = {
0: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray]
}
config.processNodeOutputs(
{ index: 0, type: 'OutputNode', title: 'Output A' },
{ Rendered: 1 },
{
input: { required: {} },
output: ['IMAGE', 'LATENT', 'MASK'],
output_name: ['image', 'latent', 'mask'],
output_is_list: [false, true, false]
}
)
expect(config.outputVisibility).toEqual([false, true, false])
expect(config.nodeDef?.output).toEqual(['LATENT'])
expect(config.nodeDef?.output_is_list).toEqual([true])
expect(config.nodeDef?.output_name).toEqual(['Custom Output'])
})
})
describe('GroupNodeConfig.registerFromWorkflow', () => {
it('adds missing type actions and skips registration for incomplete groups', async () => {
const groupNodes: Record<string, GroupNodeWorkflowData> = {
Broken: {
nodes: [{ index: 0, type: 'MissingNode' }],
links: [],
external: []
}
}
const missingNodeTypes: Parameters<
typeof GroupNodeConfig.registerFromWorkflow
>[1] = []
await GroupNodeConfig.registerFromWorkflow(groupNodes, missingNodeTypes)
expect(appMock.registerNodeDef).not.toHaveBeenCalled()
expect(missingNodeTypes).toHaveLength(2)
expect(missingNodeTypes[0]).toMatchObject({
type: 'MissingNode',
hint: " (In group node 'workflow>Broken')"
})
const action = missingNodeTypes[1]
if (typeof action === 'string') {
throw new Error('Expected a missing-node action entry, not a string')
}
const target = document.createElement('button')
const { callback } = action.action as {
callback: (event: MouseEvent) => void
}
const event = new MouseEvent('click')
Object.defineProperty(event, 'target', { value: target })
callback(event)
expect(groupNodes.Broken).toBeUndefined()
expect(target.textContent).toBe('Removed')
expect(target.style.pointerEvents).toBe('none')
})
it('registers complete group node types and stores their generated node defs', async () => {
addCustomNodeDefs({
ImageNode: makeNodeDef({
name: 'ImageNode',
input: { required: { image: ['IMAGE', {}] } },
output: ['IMAGE'],
output_name: ['image'],
output_is_list: [false]
})
})
LiteGraph.registered_node_types.ImageNode = class extends LGraphNode {}
await GroupNodeConfig.registerFromWorkflow(
{
Complete: {
nodes: [{ index: 0, type: 'ImageNode' }],
links: [],
external: [[0, 0, 'IMAGE']]
}
},
[]
)
expect(appMock.registerNodeDef).toHaveBeenCalledWith(
'workflow>Complete',
expect.objectContaining({
category: 'group nodes>workflow',
display_name: 'Complete',
name: 'workflow>Complete'
})
)
expect(useNodeDefStore().nodeDefsByName['workflow>Complete']).toEqual(
expect.objectContaining({
category: 'group nodes>workflow',
display_name: 'Complete',
name: 'workflow>Complete'
})
)
})
})

View File

@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import {
getSettingInfo,
@@ -10,47 +11,31 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingUI } from './useSettingUI'
const { auth, billing, dist, featureFlags, vueFlags } = vi.hoisted(() => ({
auth: { isLoggedIn: { value: false } },
billing: { isActiveSubscription: { value: false } },
dist: { isCloud: false, isDesktop: false },
featureFlags: { teamWorkspacesEnabled: false, userSecretsEnabled: false },
vueFlags: { shouldRenderVueNodes: { value: false } }
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: auth.isLoggedIn })
useCurrentUser: () => ({ isLoggedIn: ref(false) })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: billing.isActiveSubscription
})
useBillingContext: () => ({ isActiveSubscription: ref(false) })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: featureFlags
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
})
}))
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({
shouldRenderVueNodes: vueFlags.shouldRenderVueNodes
})
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return dist.isCloud
},
get isDesktop() {
return dist.isDesktop
}
isCloud: false,
isDesktop: false
}))
vi.mock('@/platform/settings/settingStore', () => ({
@@ -64,7 +49,6 @@ interface MockSettingParams {
type: string
defaultValue: unknown
category?: string[]
hideInVueNodes?: boolean
}
describe('useSettingUI', () => {
@@ -88,23 +72,13 @@ describe('useSettingUI', () => {
defaultValue: 'dark'
}
}
let settingsById: Record<string, MockSettingParams>
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
auth.isLoggedIn.value = false
billing.isActiveSubscription.value = false
dist.isCloud = false
dist.isDesktop = false
featureFlags.teamWorkspacesEnabled = false
featureFlags.userSecretsEnabled = false
vueFlags.shouldRenderVueNodes.value = false
Object.assign(window, { __CONFIG__: {} })
settingsById = mockSettings
vi.mocked(useSettingStore).mockReturnValue({
settingsById
settingsById: mockSettings
} as ReturnType<typeof useSettingStore>)
vi.mocked(getSettingInfo).mockImplementation((setting) => {
@@ -133,9 +107,9 @@ describe('useSettingUI', () => {
undefined,
'Comfy.Locale'
)
expect(defaultCategory.value).toBe(
findCategory(settingCategories.value, 'Comfy')
)
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
expect(comfyCategory).toBeDefined()
expect(defaultCategory.value).toBe(comfyCategory)
})
it('resolves different category from scrollToSettingId', () => {
@@ -147,6 +121,7 @@ describe('useSettingUI', () => {
settingCategories.value,
'Appearance'
)
expect(appearanceCategory).toBeDefined()
expect(defaultCategory.value).toBe(appearanceCategory)
})
@@ -162,82 +137,4 @@ describe('useSettingUI', () => {
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
expect(defaultCategory.value.key).toBe('about')
})
it('falls back when defaultPanel is not in the menu', () => {
const missingPanel = 'missing' as unknown as Parameters<
typeof useSettingUI
>[0]
const { defaultCategory, settingCategories } = useSettingUI(missingPanel)
expect(defaultCategory.value).toBe(settingCategories.value[0])
})
it('moves floating settings into Other and hides Vue-node-only settings', () => {
settingsById = {
Floating: {
id: 'Floating',
name: 'Floating',
type: 'boolean',
defaultValue: false
},
'Hidden.Setting': {
id: 'Hidden.Setting',
name: 'Hidden',
type: 'hidden',
defaultValue: false
},
'Vue.Hidden': {
id: 'Vue.Hidden',
name: 'Vue Hidden',
type: 'boolean',
defaultValue: false,
hideInVueNodes: true
}
}
vi.mocked(useSettingStore).mockReturnValue({
settingsById
} as ReturnType<typeof useSettingStore>)
vueFlags.shouldRenderVueNodes.value = true
const { settingCategories } = useSettingUI()
expect(settingCategories.value.map((category) => category.label)).toEqual([
'Other'
])
expect(
settingCategories.value[0].children?.map((node) => node.key)
).toEqual(['root/Floating'])
})
it('adds gated cloud, desktop, workspace, and secrets panels', () => {
auth.isLoggedIn.value = true
billing.isActiveSubscription.value = true
dist.isCloud = true
dist.isDesktop = true
featureFlags.teamWorkspacesEnabled = true
featureFlags.userSecretsEnabled = true
Object.assign(window, { __CONFIG__: { subscription_required: true } })
const { findCategoryByKey, findPanelByKey, navGroups, panels } =
useSettingUI()
expect(panels.value.map((panel) => panel.node.key)).toEqual([
'about',
'credits',
'user',
'workspace',
'keybinding',
'extension',
'server-config',
'subscription',
'secrets'
])
expect(navGroups.value.map((group) => group.title)).toEqual([
'Workspace',
'General'
])
expect(findCategoryByKey('secrets')?.key).toBe('secrets')
expect(findCategoryByKey('missing')).toBeNull()
expect(findPanelByKey('subscription')?.node.key).toBe('subscription')
expect(findPanelByKey('missing')).toBeNull()
})
})

View File

@@ -1,225 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import {
trackNodePrice,
usePartitionedBadges
} from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { toNodeId } from '@/types/nodeId'
import { NodeBadgeMode } from '@/types/nodeSource'
const { settings, nodeDefs, pricing, getNodeRevisionRefMock, getWidgetMock } =
vi.hoisted(() => ({
settings: {} as Record<string, unknown>,
nodeDefs: {} as Record<string, unknown>,
pricing: {
dynamic: false,
widgets: [] as string[],
inputs: [] as string[],
groups: [] as string[]
},
getNodeRevisionRefMock: vi.fn(() => ({ value: 0 })),
getWidgetMock: vi.fn(() => ({ value: 'widget-value' }))
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: { graph: { getNodeById: () => null, rootGraph: { id: 'g1' } } }
}
}))
vi.mock('@/composables/node/useNodePricing', () => ({
useNodePricing: () => ({
getRelevantWidgetNames: () => pricing.widgets,
hasDynamicPricing: () => pricing.dynamic,
getInputGroupPrefixes: () => pricing.groups,
getInputNames: () => pricing.inputs,
getNodeRevisionRef: getNodeRevisionRefMock
})
}))
vi.mock('@/composables/node/usePriceBadge', () => ({
usePriceBadge: () => ({
isCreditsBadge: (b: { text?: string }) => b.text?.startsWith('$') ?? false
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (key: string) => settings[key] })
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({ nodeDefsByName: nodeDefs })
}))
vi.mock('@/stores/widgetValueStore', () => ({
useWidgetValueStore: () => ({ getWidget: getWidgetMock })
}))
function nodeData(overrides: Partial<VueNodeData> = {}): VueNodeData {
return {
executing: false,
id: toNodeId(1),
mode: 0,
selected: false,
title: 'Test node',
type: 'TestNode',
apiNode: false,
badges: [],
inputs: [],
...overrides
} satisfies VueNodeData
}
function inputSlot(
name: string,
readLink: () => number | null
): INodeInputSlot {
return {
name,
type: '*',
boundingRect: [0, 0, 0, 0],
get link() {
return readLink()
},
set link(_value: number | null) {}
} as INodeInputSlot
}
function badge(text: string): LGraphBadge {
return new LGraphBadge({ text })
}
beforeEach(() => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
for (const k of Object.keys(nodeDefs)) delete nodeDefs[k]
nodeDefs['TestNode'] = { isCoreNode: false }
pricing.dynamic = false
pricing.widgets = []
pricing.inputs = []
pricing.groups = []
getNodeRevisionRefMock.mockClear()
getWidgetMock.mockClear()
})
describe('usePartitionedBadges', () => {
it('emits no core badges when every badge mode is None', () => {
const result = usePartitionedBadges(nodeData()).value
expect(result.core).toEqual([])
})
it('tracks dynamic-pricing dependencies for an api node without throwing', () => {
pricing.dynamic = true
pricing.widgets = ['seed']
pricing.inputs = ['model']
pricing.groups = ['lora']
const result = usePartitionedBadges(
nodeData({
apiNode: true,
inputs: [
inputSlot('model', () => 1),
inputSlot('lora.0', () => 2),
inputSlot('unrelated', () => null)
]
})
).value
expect(result).toHaveProperty('core')
expect(result).toHaveProperty('extension')
})
it('adds an id badge when the id mode is enabled', () => {
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
const result = usePartitionedBadges(nodeData({ id: toNodeId(7) })).value
expect(result.core).toContainEqual({ text: '#7' })
})
it('adds a lifecycle badge, trimmed of brackets', () => {
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.ShowAll
nodeDefs['TestNode'] = {
isCoreNode: false,
nodeLifeCycleBadgeText: '[BETA]'
}
const result = usePartitionedBadges(nodeData()).value
expect(result.core).toContainEqual({ text: 'BETA' })
})
it('adds a source badge for non-core nodes when source mode is on', () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
nodeDefs['TestNode'] = {
isCoreNode: false,
nodeSource: { badgeText: 'my-pack' }
}
const result = usePartitionedBadges(nodeData()).value
expect(result.core).toContainEqual({ text: 'my-pack' })
})
it('partitions extension badges (skipping the first) from credits badges', () => {
const result = usePartitionedBadges(
nodeData({
badges: [badge('skipped'), badge('ext-badge'), badge('$5 per run')]
})
).value
expect(result.extension.map((badge) => badge.text)).toEqual(['ext-badge'])
expect(result.pricing).toEqual([{ required: '$5', rest: 'per run' }])
})
it('flags hasComfyBadge for a core node with source ShowAll and no pricing', () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
nodeDefs['TestNode'] = { isCoreNode: true }
const result = usePartitionedBadges(
nodeData({ badges: [badge('x')] })
).value
expect(result.hasComfyBadge).toBe(true)
})
})
describe('trackNodePrice', () => {
it('no-ops for a node without dynamic pricing', () => {
pricing.dynamic = false
trackNodePrice({ id: '1', type: 'Static', inputs: [] })
expect(getNodeRevisionRefMock).toHaveBeenCalledWith(toNodeId('1'))
expect(getWidgetMock).not.toHaveBeenCalled()
})
it('touches widget, input, and input-group pricing dependencies', () => {
pricing.dynamic = true
pricing.widgets = ['seed']
pricing.inputs = ['model']
pricing.groups = ['lora']
let modelReads = 0
let groupReads = 0
let unrelatedReads = 0
trackNodePrice({
id: '2',
type: 'Dynamic',
inputs: [
inputSlot('model', () => {
modelReads += 1
return 1
}),
inputSlot('lora.0', () => {
groupReads += 1
return 2
}),
inputSlot('unrelated', () => {
unrelatedReads += 1
return null
})
]
})
expect(getNodeRevisionRefMock).toHaveBeenCalledWith(toNodeId('2'))
expect(getWidgetMock).toHaveBeenCalled()
expect(modelReads).toBe(1)
expect(groupReads).toBe(1)
expect(unrelatedReads).toBe(0)
})
})

View File

@@ -127,9 +127,10 @@ vi.mock('@/scripts/api', () => ({
}
}))
const revokePreviewsByExecutionId = vi.hoisted(() => vi.fn())
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
revokePreviewsByExecutionId: vi.fn()
revokePreviewsByExecutionId
})
}))
@@ -423,6 +424,124 @@ describe('useExecutionStore - nodeLocationProgressStates caching', () => {
'running'
)
})
it('keeps an existing error state when later progress maps to the same locator', () => {
store.nodeProgressStates = {
node1: {
display_node_id: '123',
state: 'error',
value: 0,
max: 100,
prompt_id: 'test',
node_id: 'node1'
},
node2: {
display_node_id: '123:456',
state: 'running',
value: 50,
max: 100,
prompt_id: 'test',
node_id: 'node2'
}
}
expect(
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
.state
).toBe('error')
})
it('ignores finished progress when current state is already running', () => {
store.nodeProgressStates = {
node1: {
display_node_id: '123',
state: 'running',
value: 5,
max: 10,
prompt_id: 'test',
node_id: 'node1'
},
node2: {
display_node_id: '123',
state: 'finished',
value: 10,
max: 10,
prompt_id: 'test',
node_id: 'node2'
}
}
expect(
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
).toMatchObject({ state: 'running', value: 5 })
})
it('keeps later running progress from moving a locator backwards', () => {
store.nodeProgressStates = {
node1: {
display_node_id: '123',
state: 'running',
value: 6,
max: 10,
prompt_id: 'test',
node_id: 'node1'
},
node2: {
display_node_id: '123',
state: 'running',
value: 8,
max: 10,
prompt_id: 'test',
node_id: 'node2'
}
}
expect(
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
).toMatchObject({ state: 'running', value: 6, max: 10 })
})
it('merges zero-max running progress without dividing by zero', () => {
store.nodeProgressStates = {
node1: {
display_node_id: '123',
state: 'pending',
value: 0,
max: 0,
prompt_id: 'test',
node_id: 'node1'
},
node2: {
display_node_id: '123',
state: 'running',
value: 0,
max: 0,
prompt_id: 'test',
node_id: 'node2'
}
}
expect(
store.nodeLocationProgressStates[createNodeLocatorId(null, toNodeId(123))]
).toMatchObject({ state: 'running', value: 0, max: 0 })
})
it('skips nested progress when the execution id cannot be resolved', () => {
vi.mocked(app.rootGraph.getNodeById).mockReturnValue(null)
store.nodeProgressStates = {
node1: {
display_node_id: '404:1',
state: 'running',
value: 5,
max: 10,
prompt_id: 'test',
node_id: 'node1'
}
}
expect(store.nodeLocationProgressStates).toHaveProperty('404')
expect(store.nodeLocationProgressStates).not.toHaveProperty('404:1')
})
})
describe('useExecutionStore - nodeProgressStatesByJob eviction', () => {
@@ -551,6 +670,31 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
expect(store.initializingJobIds).toEqual(new Set())
})
it('clears initialization ids directly', () => {
store.initializingJobIds = new Set(['job-1'])
store.clearInitializationByJobId(null)
store.clearInitializationByJobId('missing')
store.clearInitializationByJobId('job-1')
expect(store.initializingJobIds).toEqual(new Set())
})
it('checks initializing jobs by stringified id', () => {
store.initializingJobIds = new Set(['7'])
expect(store.isJobInitializing(undefined)).toBe(false)
expect(store.isJobInitializing(7)).toBe(true)
})
it('does not rewrite initializing state when no requested ids are tracked', () => {
store.initializingJobIds = new Set(['job-1'])
store.clearInitializationByJobIds(['missing'])
expect(store.initializingJobIds).toEqual(new Set(['job-1']))
})
})
describe('useExecutionStore - workflowStatus', () => {
@@ -675,6 +819,16 @@ describe('useExecutionStore - workflowStatus', () => {
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
it('leaves workflowStatus unchanged when open workflows are unchanged', async () => {
callStoreJob('job-a', workflowA)
fireExecutionSuccess('job-a')
mockOpenWorkflows.value = [workflowA, workflowB]
await nextTick()
expect(store.getWorkflowStatus(workflowA)).toBe('completed')
})
it('sets failed on execution_error', () => {
callStoreJob('job-1', workflowA)
fireExecutionStart('job-1')
@@ -691,6 +845,14 @@ describe('useExecutionStore - workflowStatus', () => {
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('handles interrupt for a queued workflow with no active job', () => {
callStoreJob('job-1', workflowA)
fireExecutionInterrupted('job-1')
expect(store.getWorkflowStatus(workflowA)).toBeUndefined()
})
it('evicts the oldest pending status once the buffer cap is exceeded', () => {
// Each start with no matching storeJob buffers a 'running' status. One
// past the cap evicts the oldest so the buffer can't grow unbounded.
@@ -900,6 +1062,35 @@ describe('useExecutionStore - progress_text startup guard', () => {
expect(mockShowTextPreview).toHaveBeenCalledWith(mockNode, 'warming up')
})
it('should ignore progress_text for another active prompt', async () => {
const mockNode = createMockLGraphNode({ id: 1 })
const { useCanvasStore } =
await import('@/renderer/core/canvas/canvasStore')
useCanvasStore().canvas = {
graph: { getNodeById: vi.fn(() => mockNode) }
} as unknown as LGraphCanvas
store.activeJobId = 'job-1'
fireProgressText({
nodeId: toNodeId('1'),
text: 'warming up',
prompt_id: 'job-2'
})
expect(mockShowTextPreview).not.toHaveBeenCalled()
})
it('should ignore progress_text without text or node id', () => {
fireProgressText({ nodeId: toNodeId('1'), text: '' })
fireProgressText({
nodeId: '' as ReturnType<typeof toNodeId>,
text: 'warming up'
})
expect(mockShowTextPreview).not.toHaveBeenCalled()
})
it('should ignore nested progress_text when the execution ID cannot be mapped', async () => {
const { useCanvasStore } =
await import('@/renderer/core/canvas/canvasStore')
@@ -915,6 +1106,19 @@ describe('useExecutionStore - progress_text startup guard', () => {
expect(mockExecutionIdToCurrentId).toHaveBeenCalledWith('1:2')
expect(mockShowTextPreview).not.toHaveBeenCalled()
})
it('should ignore progress_text when the current node id cannot be parsed', async () => {
const { useCanvasStore } =
await import('@/renderer/core/canvas/canvasStore')
useCanvasStore().canvas = {
graph: { getNodeById: vi.fn() }
} as unknown as LGraphCanvas
mockExecutionIdToCurrentId.mockReturnValue({})
fireProgressText({ nodeId: toNodeId('1:2'), text: 'warming up' })
expect(mockShowTextPreview).not.toHaveBeenCalled()
})
})
describe('useExecutionErrorStore - Node Error Lookups', () => {
@@ -1375,6 +1579,21 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(store.initializingJobIds.has('job-1')).toBe(false)
expect(store.initializingJobIds.has('job-2')).toBe(true)
})
it('captures a queued workflow path when the start event wins the race', () => {
store.queuedJobs = {
'job-1': {
nodes: {},
workflow: createQueuedWorkflow('/workflows/race.json')
}
}
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe(
'/workflows/race.json'
)
})
})
describe('execution_cached', () => {
@@ -1562,9 +1781,35 @@ describe('useExecutionStore - WebSocket event handlers', () => {
is_app_mode: true
})
})
it('uses current mode when shared queued job has no queued mode snapshot', () => {
mockAppModeState.mode.value = 'app'
mockAppModeState.isAppMode.value = true
store.queuedJobs = {
'job-1': {
nodes: {},
shareId: 'share-1'
}
}
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
})
})
})
describe('executing', () => {
it('is a no-op when there is no active job', () => {
fire('executing', null)
expect(store.activeJobId).toBeNull()
})
it('clears _executingNodeProgress and activeJobId when detail is null', () => {
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
store._executingNodeProgress = {
@@ -1590,7 +1835,32 @@ describe('useExecutionStore - WebSocket event handlers', () => {
})
})
describe('progress_state', () => {
it('does not revoke previews when the node execution id is invalid', () => {
fire('progress_state', {
prompt_id: 'job-1',
nodes: {
'': {
value: 1,
max: 2,
state: 'running',
node_id: '',
display_node_id: '',
prompt_id: 'job-1'
}
}
})
expect(store.nodeProgressStates).toHaveProperty('')
expect(revokePreviewsByExecutionId).not.toHaveBeenCalled()
})
})
describe('progress', () => {
it('reports null executing node progress before progress events arrive', () => {
expect(store.executingNodeProgress).toBeNull()
})
it('sets _executingNodeProgress from the event payload', () => {
const payload = { value: 3, max: 10, prompt_id: 'job-1', node: 'n1' }
@@ -1610,6 +1880,23 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(store.clientId).toBe('test-client')
expect(removeSpy).toHaveBeenCalledWith('status', expect.any(Function))
})
it('keeps listening when status arrives before clientId is available', async () => {
const apiModule = await import('@/scripts/api')
const removeSpy = vi.mocked(apiModule.api.removeEventListener)
apiModule.api.clientId = ''
fire('status', { exec_info: { queue_remaining: 0 } })
expect(store.clientId).toBeNull()
expect(removeSpy).not.toHaveBeenCalledWith('status', expect.any(Function))
apiModule.api.clientId = 'test-client'
fire('status', { exec_info: { queue_remaining: 0 } })
expect(store.clientId).toBe('test-client')
expect(removeSpy).toHaveBeenCalledWith('status', expect.any(Function))
})
})
describe('execution_error', () => {
@@ -1631,6 +1918,39 @@ describe('useExecutionStore - WebSocket event handlers', () => {
})
})
it('uses the message directly for service-level errors without a type', () => {
const errorStore = useExecutionErrorStore()
fire('execution_error', {
prompt_id: 'job-1',
node_id: null,
exception_message: 'Job failed before node execution',
traceback: []
})
expect(errorStore.lastPromptError).toMatchObject({
type: 'error',
message: 'Job failed before node execution',
details: ''
})
})
it('uses an empty prompt message for service-level errors without backend copy', () => {
const errorStore = useExecutionErrorStore()
fire('execution_error', {
prompt_id: 'job-1',
node_id: null,
traceback: []
})
expect(errorStore.lastPromptError).toMatchObject({
type: 'error',
message: '',
details: ''
})
})
it('routes a runtime error (with node_id) to lastExecutionError', () => {
const errorStore = useExecutionErrorStore()
@@ -1744,6 +2064,12 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(store.initializingJobIds.has('job-9')).toBe(false)
})
it('ignores notifications without text', () => {
fire('notification', { id: 'job-9' })
expect(store.initializingJobIds.has('job-9')).toBe(false)
})
})
describe('unbindExecutionEvents', () => {
@@ -1813,6 +2139,45 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
)
})
it('storeJob works without workflow metadata', () => {
const workflow = {} as Parameters<typeof store.storeJob>[0]['workflow']
const missingWorkflow = undefined as unknown as Parameters<
typeof store.storeJob
>[0]['workflow']
store.storeJob({
nodes: ['a'],
id: 'job-1',
promptOutput: {
a: createPromptNode('Node A', 'NodeA')
},
workflow
})
expect(store.queuedJobs['job-1']?.nodes).toEqual({ a: false })
expect(store.jobIdToWorkflowId.has('job-1')).toBe(false)
expect(store.jobIdToSessionWorkflowPath.has('job-1')).toBe(false)
store.storeJob({
nodes: ['b'],
id: 'job-2',
promptOutput: {
b: createPromptNode('Node B', 'NodeB')
},
workflow: missingWorkflow
})
expect(store.queuedJobs['job-2']?.nodes).toEqual({ b: false })
expect(store.queuedJobs['job-2']?.workflow).toBeUndefined()
})
it('reports zero execution progress for an active job with no nodes', () => {
store.activeJobId = 'job-1'
store.queuedJobs = { 'job-1': { nodes: {} } }
expect(store.executionProgress).toBe(0)
})
it('registerJobWorkflowIdMapping ignores empty inputs', () => {
store.registerJobWorkflowIdMapping('job-1', 'wf-1')
store.registerJobWorkflowIdMapping('', 'wf-2')
@@ -1829,4 +2194,58 @@ describe('useExecutionStore - storeJob and workflow path tracking', () => {
expect(store.jobIdToSessionWorkflowPath.get('job-1')).toBe('/b.json')
})
it('evicts the oldest workflow paths when the session map exceeds capacity', () => {
for (let i = 0; i < 4001; i++) {
store.ensureSessionWorkflowPath(`job-${i}`, `/workflow-${i}.json`)
}
expect(store.jobIdToSessionWorkflowPath.size).toBe(4000)
expect(store.jobIdToSessionWorkflowPath.has('job-0')).toBe(false)
expect(store.jobIdToSessionWorkflowPath.get('job-4000')).toBe(
'/workflow-4000.json'
)
})
it('reports whether the active workflow is running', () => {
mockActiveWorkflow.value = { path: '/workflows/foo.json' }
store.activeJobId = 'job-1'
store.ensureSessionWorkflowPath('job-1', '/workflows/foo.json')
expect(store.isActiveWorkflowRunning).toBe(true)
store.ensureSessionWorkflowPath('job-1', '/workflows/bar.json')
expect(store.isActiveWorkflowRunning).toBe(false)
mockActiveWorkflow.value = {}
expect(store.isActiveWorkflowRunning).toBe(false)
})
it('counts running jobs from progress state', () => {
store.nodeProgressStatesByJob = {
'job-1': {
a: {
value: 1,
max: 10,
state: 'running',
node_id: 'a',
display_node_id: 'a',
prompt_id: 'job-1'
}
},
'job-2': {
b: {
value: 10,
max: 10,
state: 'finished',
node_id: 'b',
display_node_id: 'b',
prompt_id: 'job-2'
}
}
}
expect(store.runningJobIds).toEqual(['job-1'])
expect(store.runningWorkflowCount).toBe(1)
})
})

View File

@@ -153,9 +153,9 @@ export const useExecutionStore = defineStore('execution', () => {
pendingWorkflowStatusByJobId.delete(jobId)
pendingWorkflowStatusByJobId.set(jobId, status)
while (pendingWorkflowStatusByJobId.size > MAX_PROGRESS_JOBS) {
const oldest = pendingWorkflowStatusByJobId.keys().next().value
if (oldest === undefined) break
pendingWorkflowStatusByJobId.delete(oldest)
pendingWorkflowStatusByJobId.delete(
pendingWorkflowStatusByJobId.keys().next().value as string
)
}
}
@@ -314,8 +314,8 @@ export const useExecutionStore = defineStore('execution', () => {
: null
)
const activeJob = computed<QueuedJob | undefined>(
() => queuedJobs.value[activeJobId.value ?? '']
const activeJob = computed<QueuedJob | undefined>(() =>
activeJobId.value ? queuedJobs.value[activeJobId.value] : undefined
)
const totalNodesToExecute = computed<number>(() => {
@@ -440,9 +440,7 @@ export const useExecutionStore = defineStore('execution', () => {
// Update the executing nodes list
if (e.detail == null) {
if (activeJobId.value) {
delete queuedJobs.value[activeJobId.value]
}
delete queuedJobs.value[activeJobId.value as JobId]
activeJobId.value = null
}
}
@@ -593,7 +591,7 @@ export const useExecutionStore = defineStore('execution', () => {
function handleCloudValidationError(
detail: ExecutionErrorWsMessage
): boolean {
const result = classifyCloudValidationError(detail.exception_message)
const result = classifyCloudValidationError(detail.exception_message ?? '')
if (!result) return false
clearInitializationByJobId(detail.prompt_id)
@@ -669,17 +667,14 @@ export const useExecutionStore = defineStore('execution', () => {
/**
* Reset execution-related state after a run completes or is stopped.
*/
function resetExecutionState(jobIdParam?: JobId | null) {
function resetExecutionState(jobId: JobId) {
executionIdToLocatorCache.clear()
nodeProgressStates.value = {}
const jobId = jobIdParam ?? activeJobId.value ?? null
if (jobId) {
const map = { ...nodeProgressStatesByJob.value }
delete map[jobId]
nodeProgressStatesByJob.value = map
useJobPreviewStore().clearPreview(jobId)
jobIdToWorkflow.delete(jobId)
}
const map = { ...nodeProgressStatesByJob.value }
delete map[jobId]
nodeProgressStatesByJob.value = map
useJobPreviewStore().clearPreview(jobId)
jobIdToWorkflow.delete(jobId)
if (activeJobId.value) {
delete queuedJobs.value[activeJobId.value]
}
@@ -771,9 +766,7 @@ export const useExecutionStore = defineStore('execution', () => {
const next = new Map(jobIdToSessionWorkflowPath.value)
next.set(jobId, path)
while (next.size > MAX_SESSION_PATH_ENTRIES) {
const oldest = next.keys().next().value
if (oldest !== undefined) next.delete(oldest)
else break
next.delete(next.keys().next().value as JobId)
}
jobIdToSessionWorkflowPath.value = next
}

View File

@@ -1,39 +1,14 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
import type {
IBaseWidget,
IComboWidget
} from '@/lib/litegraph/src/types/widgets'
import { toNodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import {
addToComboValues,
compressWidgetInputSlots,
createNode,
executeWidgetsCallback,
getItemsColorOption,
getLinkTypeColor,
getWidgetIdForNode,
isAnimatedOutput,
isAudioNode,
isImageNode,
isLoad3dNode,
isVideoNode,
isVideoOutput,
migrateWidgetsValues,
resolveComboValues,
resolveNode,
resolveNodeWidget
} from './litegraphUtil'
import { createNode, getWidgetIdForNode, resolveNode } from './litegraphUtil'
const mockBringNodeToFront = vi.fn()
@@ -216,233 +191,3 @@ describe('getWidgetIdForNode', () => {
expect(getWidgetIdForNode(node, { name: 'x' })).toBeUndefined()
})
})
describe('media helpers', () => {
it('classifies preview media nodes', () => {
expect(isImageNode(undefined)).toBe(false)
expect(isVideoNode(undefined)).toBe(false)
expect(isAudioNode(undefined)).toBe(false)
const imageNode = new LGraphNode('Image')
imageNode.previewMediaType = 'image'
const imageWithImgs = Object.assign(new LGraphNode('Image'), {
previewMediaType: 'model' as const,
imgs: [document.createElement('img')]
})
const videoWithImgs = Object.assign(new LGraphNode('Video'), {
previewMediaType: 'video' as const,
imgs: [document.createElement('img')]
})
const videoNode = new LGraphNode('Video')
videoNode.previewMediaType = 'video'
const videoContainerNode = Object.assign(new LGraphNode('Video'), {
videoContainer: document.body
})
const audioNode = new LGraphNode('Audio')
audioNode.previewMediaType = 'audio'
expect(isImageNode(imageNode)).toBe(true)
expect(isImageNode(imageWithImgs)).toBe(true)
expect(isImageNode(videoWithImgs)).toBe(false)
expect(isVideoNode(videoNode)).toBe(true)
expect(isVideoNode(videoContainerNode)).toBe(true)
expect(isAudioNode(audioNode)).toBe(true)
})
it('distinguishes animated images from video outputs', () => {
expect(isAnimatedOutput(undefined)).toBe(false)
expect(isAnimatedOutput({ animated: [false, true] })).toBe(true)
expect(
isVideoOutput({
animated: [true],
images: [{ filename: 'clip.mp4' }]
})
).toBe(true)
expect(
isVideoOutput({
animated: [true],
images: [{ filename: 'preview.webp' }]
})
).toBe(false)
expect(
isVideoOutput({
animated: [true],
images: [{ filename: 'preview.png' }]
})
).toBe(false)
})
it('detects 3d loader nodes', () => {
const modelNode = new LGraphNode('Load3D')
modelNode.type = 'Load3D'
const animationNode = new LGraphNode('Load3DAnimation')
animationNode.type = 'Load3DAnimation'
const imageNode = new LGraphNode('LoadImage')
imageNode.type = 'LoadImage'
expect(isLoad3dNode(modelNode)).toBe(true)
expect(isLoad3dNode(animationNode)).toBe(true)
expect(isLoad3dNode(imageNode)).toBe(false)
})
})
describe('combo widget helpers', () => {
function combo(values: IComboWidget['options']['values']): IComboWidget {
return fromPartial<IComboWidget>({
name: 'mode',
type: 'combo',
value: 'a',
options: { values }
})
}
it('resolves combo values from arrays, records, functions, and missing options', () => {
expect(resolveComboValues(combo(['a', 'b']))).toEqual(['a', 'b'])
expect(resolveComboValues(combo({ a: 'A', b: 'B' }))).toEqual(['a', 'b'])
expect(resolveComboValues(combo(() => ['x']))).toEqual(['x'])
expect(
resolveComboValues(fromPartial<IComboWidget>({ options: {} }))
).toEqual([])
})
it('adds only missing array combo values', () => {
const widget = combo(['a'])
addToComboValues(widget, 'b')
addToComboValues(widget, 'b')
expect(widget.options.values).toEqual(['a', 'b'])
})
})
describe('node utility helpers', () => {
it('returns a shared color option only when all colorable items match', () => {
const red = { getColorOption: () => 'red', setColorOption: vi.fn() }
const redAgain = { getColorOption: () => 'red', setColorOption: vi.fn() }
const blue = { getColorOption: () => 'blue', setColorOption: vi.fn() }
expect(getItemsColorOption([red, redAgain, {}])).toBe('red')
expect(getItemsColorOption([red, blue])).toBeNull()
expect(getItemsColorOption([{}])).toBeNull()
})
it('executes matching callbacks on node widgets', () => {
const onRemove = vi.fn()
const afterQueued = vi.fn()
const node = new LGraphNode('Callbacks')
node.widgets = [
fromPartial<IBaseWidget>({ onRemove }),
fromPartial<IBaseWidget>({ afterQueued })
]
executeWidgetsCallback([node], 'onRemove')
expect(onRemove).toHaveBeenCalledOnce()
expect(afterQueued).not.toHaveBeenCalled()
})
it('returns configured link colors with the default fallback', () => {
expect(getLinkTypeColor('missing-type')).toBe(LiteGraph.LINK_COLOR)
})
})
describe('legacy workflow migration helpers', () => {
it('drops legacy force-input widget values only when lengths match', () => {
const inputDefs = {
seed: { name: 'seed', type: 'INT', forceInput: true },
mode: { name: 'mode', type: 'STRING' },
batch: {
name: 'batch',
type: 'INT',
control_after_generate: true
}
}
const widgets = [
fromPartial<IBaseWidget>({ name: 'mode' }),
fromPartial<IBaseWidget>({ name: 'batch' })
]
expect(migrateWidgetsValues(inputDefs, widgets, [1, 2, 3, 4])).toEqual([
2, 3, 4
])
expect(migrateWidgetsValues(inputDefs, widgets, [1, 2])).toEqual([1, 2])
})
it('compresses root and subgraph widget input slots', () => {
const graph = fromPartial<ISerialisedGraph>({
nodes: [
{
id: 1,
type: 'Node',
inputs: [
{
name: 'widget',
type: 'STRING',
link: null,
widget: { name: 'w' }
},
{ name: 'kept', type: 'STRING', link: 7 }
]
}
],
links: [[7, 2, 0, 1, 99, 'STRING']],
definitions: {
subgraphs: [
{
name: 'Subgraph',
nodes: [
{
id: 3,
type: 'Inner',
inputs: [
{
name: 'legacy',
type: 'STRING',
link: null,
widget: { name: 'legacy' }
},
{ name: 'inner', type: 'STRING', link: 8 }
]
}
],
links: [
{
id: 8,
origin_id: 4,
origin_slot: 0,
target_id: 3,
target_slot: 42,
type: 'STRING'
}
]
}
]
}
})
compressWidgetInputSlots(graph)
expect(graph.nodes[0].inputs?.map((input) => input.name)).toEqual(['kept'])
expect(graph.links[0][4]).toBe(0)
const subgraph = graph.definitions?.subgraphs?.[0]
expect(subgraph?.nodes?.[0].inputs?.map((input) => input.name)).toEqual([
'inner'
])
expect(subgraph?.links?.[0].target_slot).toBe(0)
})
})
describe('resolveNodeWidget', () => {
it('resolves root graph nodes and widgets', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
const node = new LGraphNode('TestNode')
const widget = node.addWidget('text', 'prompt', 'hello', () => {})
graph.add(node)
expect(resolveNodeWidget(node.id, undefined, graph)).toEqual([node])
expect(resolveNodeWidget(node.id, 'prompt', graph)).toEqual([node, widget])
expect(resolveNodeWidget(node.id, 'missing', graph)).toEqual([])
expect(resolveNodeWidget('not-a-node-id', 'prompt', graph)).toEqual([])
})
})

View File

@@ -1,85 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { components } from '@/types/comfyRegistryTypes'
import { registryToFrontendV2NodeDef } from '@/utils/mapperUtil'
type RegistryNode = components['schemas']['ComfyNode']
type RegistryPack = components['schemas']['Node']
function nodeDef(over: Partial<RegistryNode> = {}): RegistryNode {
return over as RegistryNode
}
function pack(over: Partial<RegistryPack> = {}): RegistryPack {
return over as RegistryPack
}
describe('registryToFrontendV2NodeDef', () => {
it('maps outputs, defaulting names to types and is_list to false', () => {
const def = registryToFrontendV2NodeDef(
nodeDef({
return_types: '["INT","IMAGE"]',
return_names: '["count",""]',
output_is_list: [true]
}),
pack()
)
expect(def.outputs).toEqual([
{ type: 'INT', name: 'count', is_list: true, index: 0 },
{ type: 'IMAGE', name: 'IMAGE', is_list: false, index: 1 }
])
})
it('returns no outputs when return_types is empty or absent', () => {
expect(
registryToFrontendV2NodeDef(nodeDef({ return_types: '[]' }), pack())
.outputs
).toEqual([])
expect(registryToFrontendV2NodeDef(nodeDef(), pack()).outputs).toEqual([])
})
it('maps required and optional inputs into keyed specs', () => {
const def = registryToFrontendV2NodeDef(
nodeDef({
input_types: JSON.stringify({
required: { seed: ['INT', { default: 0 }] },
optional: { label: ['STRING', {}] }
})
}),
pack()
)
expect(def.inputs).toEqual({
seed: { type: 'INT', name: 'seed', isOptional: false, default: 0 },
label: { type: 'STRING', name: 'label', isOptional: true }
})
})
it('returns no inputs when input_types is empty or absent', () => {
expect(registryToFrontendV2NodeDef(nodeDef(), pack()).inputs).toEqual({})
expect(
registryToFrontendV2NodeDef(nodeDef({ input_types: '{}' }), pack()).inputs
).toEqual({})
})
it('applies field fallbacks for name, category, and python_module', () => {
const def = registryToFrontendV2NodeDef(nodeDef(), pack({ id: 'pack-id' }))
expect(def.name).toBe('Node Name')
expect(def.display_name).toBe('Node Name')
expect(def.category).toBe('unknown')
expect(def.python_module).toBe('pack-id') // name absent -> falls back to id
})
it('prefers explicit values over fallbacks', () => {
const def = registryToFrontendV2NodeDef(
nodeDef({ comfy_node_name: 'KSampler', category: 'sampling' }),
pack({ name: 'comfy-core' })
)
expect(def.name).toBe('KSampler')
expect(def.category).toBe('sampling')
expect(def.python_module).toBe('comfy-core')
})
})