V2 Node Search (+ hidden Node Library changes) (#8987)

## Summary

Redesigned node search with categories

## Changes

- **What**: Adds a v2 search component, leaving the existing
implementation untouched
- It also brings onboard the incomplete node library & preview changes,
disabled and behind a hidden setting
- **Breaking**: Changes the 'default' value of the node search setting
to v2, adding v1 (legacy) as an option

## Screenshots (if applicable)




https://github.com/user-attachments/assets/2ab797df-58f0-48e8-8b20-2a1809e3735f

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8987-V2-Node-Search-hidden-Node-Library-changes-30c6d73d36508160902bcb92553f147c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
pythongosssss
2026-02-20 09:10:03 +00:00
committed by GitHub
parent 8f5cdead73
commit 6902e38e6a
183 changed files with 7972 additions and 127 deletions

View File

@@ -0,0 +1,342 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { useNodeDragToCanvas as UseNodeDragToCanvasType } from './useNodeDragToCanvas'
const { mockAddNodeOnGraph, mockConvertEventToCanvasOffset, mockCanvas } =
vi.hoisted(() => {
const mockConvertEventToCanvasOffset = vi.fn()
return {
mockAddNodeOnGraph: vi.fn(),
mockConvertEventToCanvasOffset,
mockCanvas: {
canvas: {
getBoundingClientRect: vi.fn()
},
convertEventToCanvasOffset: mockConvertEventToCanvasOffset
}
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => ({
canvas: mockCanvas
}))
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({
addNodeOnGraph: mockAddNodeOnGraph
}))
}))
describe('useNodeDragToCanvas', () => {
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
const mockNodeDef = {
name: 'TestNode',
display_name: 'Test Node'
} as ComfyNodeDefImpl
beforeEach(async () => {
vi.resetModules()
vi.resetAllMocks()
const module = await import('./useNodeDragToCanvas')
useNodeDragToCanvas = module.useNodeDragToCanvas
})
afterEach(() => {
const { cleanupGlobalListeners } = useNodeDragToCanvas()
cleanupGlobalListeners()
vi.restoreAllMocks()
})
describe('startDrag', () => {
it('should set isDragging to true and store the node definition', () => {
const { isDragging, draggedNode, startDrag } = useNodeDragToCanvas()
expect(isDragging.value).toBe(false)
expect(draggedNode.value).toBeNull()
startDrag(mockNodeDef)
expect(isDragging.value).toBe(true)
expect(draggedNode.value).toBe(mockNodeDef)
})
it('should set dragMode to click by default', () => {
const { dragMode, startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dragMode.value).toBe('click')
})
it('should set dragMode to native when specified', () => {
const { dragMode, startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
expect(dragMode.value).toBe('native')
})
})
describe('cancelDrag', () => {
it('should reset isDragging and draggedNode', () => {
const { isDragging, draggedNode, startDrag, cancelDrag } =
useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(isDragging.value).toBe(true)
cancelDrag()
expect(isDragging.value).toBe(false)
expect(draggedNode.value).toBeNull()
})
it('should reset dragMode to click', () => {
const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
expect(dragMode.value).toBe('native')
cancelDrag()
expect(dragMode.value).toBe('click')
})
})
describe('setupGlobalListeners', () => {
it('should add event listeners to document', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointermove',
expect.any(Function)
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
true
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'keydown',
expect.any(Function)
)
})
it('should only setup listeners once', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const callCount = addEventListenerSpy.mock.calls.length
setupGlobalListeners()
expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
})
})
describe('cursorPosition', () => {
it('should update on pointermove', () => {
const { cursorPosition, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const pointerEvent = new PointerEvent('pointermove', {
clientX: 100,
clientY: 200
})
document.dispatchEvent(pointerEvent)
expect(cursorPosition.value).toEqual({ x: 100, y: 200 })
})
})
describe('endDrag behavior', () => {
it('should add node when pointer is over canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
document.dispatchEvent(pointerEvent)
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
pos: [150, 150]
})
})
it('should not add node when pointer is outside canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
clientX: 600,
clientY: 250,
bubbles: true
})
document.dispatchEvent(pointerEvent)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(isDragging.value).toBe(false)
})
it('should cancel drag on Escape key', () => {
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
expect(isDragging.value).toBe(true)
const keyEvent = new KeyboardEvent('keydown', { key: 'Escape' })
document.dispatchEvent(keyEvent)
expect(isDragging.value).toBe(false)
})
it('should not cancel drag on other keys', () => {
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' })
document.dispatchEvent(keyEvent)
expect(isDragging.value).toBe(true)
})
it('should not add node on pointerup when in native drag mode', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const pointerEvent = new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
document.dispatchEvent(pointerEvent)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(isDragging.value).toBe(true)
})
})
describe('handleNativeDrop', () => {
it('should add node when drop position is over canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
handleNativeDrop(250, 250)
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
pos: [200, 200]
})
})
it('should not add node when drop position is outside canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
handleNativeDrop(600, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
expect(isDragging.value).toBe(false)
})
it('should not add node when dragMode is click', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'click')
handleNativeDrop(250, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
})
it('should reset drag state after drop', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop, isDragging, dragMode } =
useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
handleNativeDrop(250, 250)
expect(isDragging.value).toBe(false)
expect(dragMode.value).toBe('click')
})
})
})

View File

@@ -0,0 +1,116 @@
import { ref, shallowRef } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type DragMode = 'click' | 'native'
const isDragging = ref(false)
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
const cursorPosition = ref({ x: 0, y: 0 })
const dragMode = ref<DragMode>('click')
let listenersSetup = false
function updatePosition(e: PointerEvent) {
cursorPosition.value = { x: e.clientX, y: e.clientY }
}
function cancelDrag() {
isDragging.value = false
draggedNode.value = null
dragMode.value = 'click'
}
function addNodeAtPosition(clientX: number, clientY: number): boolean {
if (!draggedNode.value) return false
const canvasStore = useCanvasStore()
const canvas = canvasStore.canvas
if (!canvas) return false
const canvasElement = canvas.canvas as HTMLCanvasElement
const rect = canvasElement.getBoundingClientRect()
const isOverCanvas =
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
if (isOverCanvas) {
const pos = canvas.convertEventToCanvasOffset({
clientX,
clientY
} as PointerEvent)
const litegraphService = useLitegraphService()
litegraphService.addNodeOnGraph(draggedNode.value, { pos })
return true
}
return false
}
function endDrag(e: PointerEvent) {
if (!isDragging.value || !draggedNode.value) return
if (dragMode.value !== 'click') return
try {
addNodeAtPosition(e.clientX, e.clientY)
} finally {
cancelDrag()
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancelDrag()
}
function setupGlobalListeners() {
if (listenersSetup) return
listenersSetup = true
document.addEventListener('pointermove', updatePosition)
document.addEventListener('pointerup', endDrag, true)
document.addEventListener('keydown', handleKeydown)
}
function cleanupGlobalListeners() {
if (!listenersSetup) return
listenersSetup = false
document.removeEventListener('pointermove', updatePosition)
document.removeEventListener('pointerup', endDrag, true)
document.removeEventListener('keydown', handleKeydown)
if (isDragging.value && dragMode.value === 'click') {
cancelDrag()
}
}
export function useNodeDragToCanvas() {
function startDrag(nodeDef: ComfyNodeDefImpl, mode: DragMode = 'click') {
isDragging.value = true
draggedNode.value = nodeDef
dragMode.value = mode
}
function handleNativeDrop(clientX: number, clientY: number) {
if (dragMode.value !== 'native') return
try {
addNodeAtPosition(clientX, clientY)
} finally {
cancelDrag()
}
}
return {
isDragging,
draggedNode,
cursorPosition,
dragMode,
startDrag,
cancelDrag,
handleNativeDrop,
setupGlobalListeners,
cleanupGlobalListeners
}
}

View File

@@ -0,0 +1,179 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodePreviewAndDrag } from './useNodePreviewAndDrag'
const mockStartDrag = vi.fn()
const mockHandleNativeDrop = vi.fn()
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
startDrag: mockStartDrag,
handleNativeDrop: mockHandleNativeDrop
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue('left')
})
}))
describe('useNodePreviewAndDrag', () => {
const mockNodeDef = {
name: 'TestNode',
display_name: 'Test Node'
} as ComfyNodeDefImpl
beforeEach(() => {
vi.clearAllMocks()
})
describe('initial state', () => {
it('should initialize with correct default values', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
expect(result.isHovered.value).toBe(false)
expect(result.isDragging.value).toBe(false)
expect(result.showPreview.value).toBe(false)
expect(result.previewRef.value).toBeNull()
})
it('should compute showPreview based on hover and drag state', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
result.isHovered.value = true
expect(result.showPreview.value).toBe(true)
result.isDragging.value = true
expect(result.showPreview.value).toBe(false)
})
})
describe('handleMouseEnter', () => {
it('should set isHovered to true when nodeDef exists', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
const mockElement = document.createElement('div')
vi.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({
top: 100,
left: 50,
right: 150,
bottom: 200,
width: 100,
height: 100,
x: 50,
y: 100,
toJSON: () => ({})
})
const mockEvent = { currentTarget: mockElement } as unknown as MouseEvent
result.handleMouseEnter(mockEvent)
expect(result.isHovered.value).toBe(true)
})
it('should not set isHovered when nodeDef is undefined', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(undefined)
const result = useNodePreviewAndDrag(nodeDef)
const mockElement = document.createElement('div')
const mockEvent = { currentTarget: mockElement } as unknown as MouseEvent
result.handleMouseEnter(mockEvent)
expect(result.isHovered.value).toBe(false)
})
})
describe('handleMouseLeave', () => {
it('should set isHovered to false', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
result.isHovered.value = true
result.handleMouseLeave()
expect(result.isHovered.value).toBe(false)
})
})
describe('handleDragStart', () => {
it('should call startDrag with native mode when nodeDef exists', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
const mockDataTransfer = {
effectAllowed: '',
setData: vi.fn(),
setDragImage: vi.fn()
}
const mockEvent = {
dataTransfer: mockDataTransfer
} as unknown as DragEvent
result.handleDragStart(mockEvent)
expect(result.isDragging.value).toBe(true)
expect(result.isHovered.value).toBe(false)
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, 'native')
expect(mockDataTransfer.effectAllowed).toBe('copy')
expect(mockDataTransfer.setData).toHaveBeenCalledWith(
'application/x-comfy-node',
'TestNode'
)
})
it('should not start drag when nodeDef is undefined', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(undefined)
const result = useNodePreviewAndDrag(nodeDef)
const mockEvent = { dataTransfer: null } as DragEvent
result.handleDragStart(mockEvent)
expect(result.isDragging.value).toBe(false)
expect(mockStartDrag).not.toHaveBeenCalled()
})
})
describe('handleDragEnd', () => {
it('should call handleNativeDrop with drop coordinates', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
result.isDragging.value = true
const mockEvent = {
clientX: 100,
clientY: 200
} as unknown as DragEvent
result.handleDragEnd(mockEvent)
expect(result.isDragging.value).toBe(false)
expect(mockHandleNativeDrop).toHaveBeenCalledWith(100, 200)
})
it('should always call handleNativeDrop regardless of dropEffect', () => {
const nodeDef = ref<ComfyNodeDefImpl | undefined>(mockNodeDef)
const result = useNodePreviewAndDrag(nodeDef)
result.isDragging.value = true
const mockEvent = {
dataTransfer: { dropEffect: 'none' },
clientX: 300,
clientY: 400
} as unknown as DragEvent
result.handleDragEnd(mockEvent)
expect(result.isDragging.value).toBe(false)
expect(mockHandleNativeDrop).toHaveBeenCalledWith(300, 400)
})
})
})

View File

@@ -0,0 +1,149 @@
import type { CSSProperties, Ref } from 'vue'
import { computed, ref } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const PREVIEW_WIDTH = 200
const PREVIEW_MARGIN = 16
export function useNodePreviewAndDrag(
nodeDef: Ref<ComfyNodeDefImpl | undefined>,
options?: { panelRef?: Ref<HTMLElement | null> }
) {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
const settingStore = useSettingStore()
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const previewRef = ref<HTMLElement | null>(null)
const isHovered = ref(false)
const isDragging = ref(false)
const showPreview = computed(() => isHovered.value && !isDragging.value)
const nodePreviewStyle = ref<CSSProperties>({
position: 'fixed',
top: '0px',
left: '0px',
pointerEvents: 'none',
zIndex: 1000
})
function calculatePreviewPosition(rect: DOMRect) {
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
let left: number
if (sidebarLocation.value === 'left') {
left = rect.right + PREVIEW_MARGIN
if (left + PREVIEW_WIDTH > viewportWidth) {
left = rect.left - PREVIEW_MARGIN - PREVIEW_WIDTH
}
} else {
left = rect.left - PREVIEW_MARGIN - PREVIEW_WIDTH
if (left < 0) {
left = rect.right + PREVIEW_MARGIN
}
}
return { left, viewportHeight }
}
function handleMouseEnter(e: MouseEvent) {
if (!nodeDef.value) return
const target = e.currentTarget as HTMLElement
const rect = target.getBoundingClientRect()
const horizontalRect =
options?.panelRef?.value?.getBoundingClientRect() ?? rect
const { left, viewportHeight } = calculatePreviewPosition(horizontalRect)
let top = rect.top
nodePreviewStyle.value = {
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
pointerEvents: 'none',
zIndex: 1000,
opacity: 0
}
isHovered.value = true
requestAnimationFrame(() => {
if (previewRef.value) {
const previewRect = previewRef.value.getBoundingClientRect()
const previewHeight = previewRect.height
const mouseY = rect.top + rect.height / 2
top = mouseY - previewHeight * 0.3
const minTop = PREVIEW_MARGIN
const maxTop = viewportHeight - previewHeight - PREVIEW_MARGIN
top = Math.max(minTop, Math.min(top, maxTop))
nodePreviewStyle.value = {
...nodePreviewStyle.value,
top: `${top}px`,
opacity: 1
}
}
})
}
function handleMouseLeave() {
isHovered.value = false
}
function createEmptyDragImage(): HTMLElement {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.left = '-9999px'
el.style.top = '-9999px'
el.style.width = '1px'
el.style.height = '1px'
return el
}
function handleDragStart(e: DragEvent) {
if (!nodeDef.value) return
isDragging.value = true
isHovered.value = false
startDrag(nodeDef.value, 'native')
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'copy'
e.dataTransfer.setData('application/x-comfy-node', nodeDef.value.name)
const dragImage = createEmptyDragImage()
document.body.appendChild(dragImage)
e.dataTransfer.setDragImage(dragImage, 0, 0)
requestAnimationFrame(() => {
document.body.removeChild(dragImage)
})
}
}
function handleDragEnd(e: DragEvent) {
isDragging.value = false
handleNativeDrop(e.clientX, e.clientY)
}
return {
previewRef,
isHovered,
isDragging,
showPreview,
nodePreviewStyle,
sidebarLocation,
handleMouseEnter,
handleMouseLeave,
handleDragStart,
handleDragEnd
}
}

View File

@@ -1,9 +1,16 @@
import { describe, expect, it } from 'vitest'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import { useNodePricing } from '@/composables/node/useNodePricing'
import {
evaluateNodeDefPricing,
formatCreditsListValue,
formatCreditsRangeValue,
formatCreditsValue,
formatPricingResult,
useNodePricing
} from '@/composables/node/useNodePricing'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { PriceBadge } from '@/schemas/nodeDefSchema'
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
// -----------------------------------------------------------------------------
@@ -673,18 +680,19 @@ describe('useNodePricing', () => {
expect(price).toBe('')
})
it('should return empty string for PricingResult missing type field', async () => {
it('should handle legacy format without type field', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
'TestMissingTypeNode',
// Returns object without type field
'TestLegacyFormatNode',
// Returns object without type field (legacy format)
priceBadge('{"usd":0.05}')
)
getNodeDisplayPrice(node)
await new Promise((r) => setTimeout(r, 50))
const price = getNodeDisplayPrice(node)
expect(price).toBe('')
// Legacy format {usd: number} is supported
expect(price).toBe(creditsLabel(0.05))
})
it('should return empty string for non-object result', async () => {
@@ -855,3 +863,362 @@ describe('useNodePricing', () => {
})
})
})
// -----------------------------------------------------------------------------
// formatPricingResult Tests
// -----------------------------------------------------------------------------
describe('formatPricingResult', () => {
describe('type: usd', () => {
it('should format usd result', () => {
const result = formatPricingResult({ type: 'usd', usd: 0.05 })
expect(result).toBe('10.6 credits/Run')
})
it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'usd', usd: 0.05 },
{ valueOnly: true }
)
expect(result).toBe('10.6')
})
it('should handle approximate prefix in valueOnly mode', () => {
const result = formatPricingResult(
{ type: 'usd', usd: 0.05, format: { approximate: true } },
{ valueOnly: true }
)
expect(result).toBe('~10.6')
})
it('should return empty for null usd', () => {
const result = formatPricingResult({ type: 'usd', usd: null as never })
expect(result).toBe('')
})
})
describe('type: range_usd', () => {
it('should format range result', () => {
const result = formatPricingResult({
type: 'range_usd',
min_usd: 0.05,
max_usd: 0.1
})
expect(result).toBe('10.6-21.1 credits/Run')
})
it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: 0.05, max_usd: 0.1 },
{ valueOnly: true }
)
expect(result).toBe('10.6-21.1')
})
it('should collapse range when min equals max', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: 0.05, max_usd: 0.05 },
{ valueOnly: true }
)
expect(result).toBe('10.6')
})
})
describe('type: list_usd', () => {
it('should format list result', () => {
const result = formatPricingResult({
type: 'list_usd',
usd: [0.05, 0.1, 0.15]
})
expect(result).toMatch(/\d+\.?\d*\/\d+\.?\d*\/\d+\.?\d* credits\/Run/)
})
it('should return valueOnly format', () => {
const result = formatPricingResult(
{ type: 'list_usd', usd: [0.05, 0.1] },
{ valueOnly: true }
)
expect(result).toBe('10.6/21.1')
})
})
describe('type: text', () => {
it('should return text as-is', () => {
const result = formatPricingResult({ type: 'text', text: 'Free' })
expect(result).toBe('Free')
})
})
describe('legacy format', () => {
it('should handle {usd: number} without type field', () => {
const result = formatPricingResult({ usd: 0.05 })
expect(result).toBe('10.6 credits/Run')
})
it('should return valueOnly for legacy format', () => {
const result = formatPricingResult({ usd: 0.05 }, { valueOnly: true })
expect(result).toBe('10.6')
})
})
describe('invalid inputs', () => {
it('should return empty for invalid type', () => {
const result = formatPricingResult({ type: 'invalid' })
expect(result).toBe('')
})
it('should return empty for null', () => {
const result = formatPricingResult(null)
expect(result).toBe('')
})
it('should return empty for undefined', () => {
const result = formatPricingResult(undefined)
expect(result).toBe('')
})
})
})
// -----------------------------------------------------------------------------
// formatCreditsValue / Range / List Tests
// -----------------------------------------------------------------------------
describe('formatCreditsValue', () => {
it('should format USD to credits', () => {
expect(formatCreditsValue(0.05)).toBe('10.6')
expect(formatCreditsValue(1.0)).toBe('211')
})
})
describe('formatCreditsRangeValue', () => {
it('should format min-max range', () => {
expect(formatCreditsRangeValue(0.05, 0.1)).toBe('10.6-21.1')
})
it('should collapse when min equals max', () => {
expect(formatCreditsRangeValue(0.05, 0.05)).toBe('10.6')
})
})
describe('formatCreditsListValue', () => {
it('should join values with separator', () => {
expect(formatCreditsListValue([0.05, 0.1])).toBe('10.6/21.1')
})
it('should use custom separator', () => {
expect(formatCreditsListValue([0.05, 0.1], ' | ')).toBe('10.6 | 21.1')
})
})
// -----------------------------------------------------------------------------
// evaluateNodeDefPricing Tests
// -----------------------------------------------------------------------------
describe('evaluateNodeDefPricing', () => {
const createMockNodeDef = (
overrides: Partial<ComfyNodeDef> = {}
): ComfyNodeDef =>
({
name: 'TestNode',
display_name: 'Test Node',
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
python_module: 'test',
...overrides
}) as ComfyNodeDef
it('should return empty for node without price_badge', async () => {
const nodeDef = createMockNodeDef()
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('')
})
it('should evaluate static expression', async () => {
const nodeDef = createMockNodeDef({
name: 'StaticPriceNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd":0.05}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})
it('should use default value from input spec', async () => {
const nodeDef = createMockNodeDef({
name: 'DefaultValueNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.count * 0.01}',
depends_on: {
widgets: [{ name: 'count', type: 'INT' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
count: ['INT', { default: 10 }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
})
it('should use first option for COMBO without default', async () => {
const nodeDef = createMockNodeDef({
name: 'ComboNode',
price_badge: {
engine: 'jsonata',
expr: '(widgets.mode = "pro") ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}',
depends_on: {
widgets: [{ name: 'mode', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
mode: [['standard', 'pro'], {}]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// First option is "standard", not "pro", so should be 0.05 USD
expect(result).toBe('10.6')
})
it('should use "original" as fallback for dynamic COMBO without input', async () => {
const nodeDef = createMockNodeDef({
name: 'DynamicComboNode',
price_badge: {
engine: 'jsonata',
expr: `(
$prices := {"original": 0.05, "720p": 0.03};
{"type":"usd","usd": $lookup($prices, widgets.resolution)}
)`,
depends_on: {
widgets: [{ name: 'resolution', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
// resolution widget is NOT in inputs (dynamically created)
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// Fallback to "original" = 0.05 USD
expect(result).toBe('10.6')
})
it('should handle dynamic combo with options array', async () => {
const nodeDef = createMockNodeDef({
name: 'DynamicOptionsNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.model = "model_a" ? 0.05 : 0.10}',
depends_on: {
widgets: [{ name: 'model', type: 'COMFY_DYNAMICCOMBO_V3' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
model: [
'COMFY_DYNAMICCOMBO_V3',
{ options: [{ key: 'model_a' }, { key: 'model_b' }] }
]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// First option key is "model_a" = 0.05 USD
expect(result).toBe('10.6')
})
it('should assume inputs disconnected in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputConnectedNode',
price_badge: {
engine: 'jsonata',
expr: 'inputs.image.connected ? {"type":"usd","usd":0.10} : {"type":"usd","usd":0.05}',
depends_on: {
widgets: [],
inputs: ['image'],
input_groups: []
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// In preview, inputs are assumed disconnected
expect(result).toBe('10.6')
})
it('should assume inputGroups have 0 count in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputGroupNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": 0.05 + inputGroups.videos * 0.02}',
depends_on: {
widgets: [],
inputs: [],
input_groups: ['videos']
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
// 0.05 + 0 * 0.02 = 0.05 USD
expect(result).toBe('10.6')
})
it('should return empty on JSONata error', async () => {
const nodeDef = createMockNodeDef({
name: 'ErrorNode',
price_badge: {
engine: 'jsonata',
expr: '$lookup(undefined, "key")',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('')
})
it('should handle range_usd result', async () => {
const nodeDef = createMockNodeDef({
name: 'RangeNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"range_usd","min_usd":0.05,"max_usd":0.10}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6-21.1')
})
it('should handle approximate format in valueOnly mode', async () => {
const nodeDef = createMockNodeDef({
name: 'ApproximateNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd":0.05,"format":{"approximate":true}}',
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('~10.6')
})
})

View File

@@ -10,6 +10,7 @@
// - async evaluation + cache,
// - reactive tick to update UI when async evaluation completes.
import { memoize } from 'es-toolkit'
import { readonly, ref } from 'vue'
import type { Ref } from 'vue'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
@@ -47,7 +48,7 @@ type CreditFormatOptions = {
separator?: string
}
const formatCreditsValue = (usd: number): string => {
export const formatCreditsValue = (usd: number): string => {
// Use raw credits value (before rounding) to determine decimal display
const rawCredits = usd * CREDITS_PER_USD
return formatCredits({
@@ -68,23 +69,37 @@ const formatCreditsLabel = (
): string =>
`${makePrefix(approximate)}${formatCreditsValue(usd)} credits${makeSuffix(suffix)}${appendNote(note)}`
export const formatCreditsRangeValue = (
minUsd: number,
maxUsd: number
): string => {
const min = formatCreditsValue(minUsd)
const max = formatCreditsValue(maxUsd)
return min === max ? min : `${min}-${max}`
}
const formatCreditsRangeLabel = (
minUsd: number,
maxUsd: number,
{ suffix, note, approximate }: CreditFormatOptions = {}
): string => {
const min = formatCreditsValue(minUsd)
const max = formatCreditsValue(maxUsd)
const rangeValue = min === max ? min : `${min}-${max}`
const rangeValue = formatCreditsRangeValue(minUsd, maxUsd)
return `${makePrefix(approximate)}${rangeValue} credits${makeSuffix(suffix)}${appendNote(note)}`
}
export const formatCreditsListValue = (
usdValues: number[],
separator = '/'
): string => {
const parts = usdValues.map((value) => formatCreditsValue(value))
return parts.join(separator)
}
const formatCreditsListLabel = (
usdValues: number[],
{ suffix, note, approximate, separator }: CreditFormatOptions = {}
): string => {
const parts = usdValues.map((value) => formatCreditsValue(value))
const value = parts.join(separator ?? '/')
const value = formatCreditsListValue(usdValues, separator)
return `${makePrefix(approximate)}${value} credits${makeSuffix(suffix)}${appendNote(note)}`
}
@@ -130,7 +145,6 @@ type JsonataPricingRule = {
input_groups: string[]
}
expr: string
result_defaults?: CreditFormatOptions
}
type CompiledJsonataPricingRule = JsonataPricingRule & {
@@ -283,10 +297,39 @@ const buildSignature = (
// -----------------------------
// Result formatting
// -----------------------------
const formatPricingResult = (
type FormatPricingResultOptions = {
/** If true, return only the value without "credits/Run" suffix */
valueOnly?: boolean
defaults?: CreditFormatOptions
}
/**
* Format a PricingResult into a display string.
* @param result - The pricing result from JSONata evaluation
* @param options - Formatting options
* @returns Formatted string, e.g. "10 credits/Run" or "10" if valueOnly
*/
export const formatPricingResult = (
result: unknown,
defaults: CreditFormatOptions = {}
options: FormatPricingResultOptions = {}
): string => {
const { valueOnly = false, defaults = {} } = options
// Handle legacy format: { usd: number } without type field
if (
result &&
typeof result === 'object' &&
!('type' in result) &&
'usd' in result
) {
const r = result as { usd: unknown }
const usd = asFiniteNumber(r.usd)
if (usd === null) return ''
if (valueOnly) return formatCreditsValue(usd)
return formatCreditsLabel(usd, defaults)
}
if (!isPricingResult(result)) {
if (result !== undefined && result !== null) {
console.warn('[pricing/jsonata] invalid result format:', result)
@@ -302,6 +345,10 @@ const formatPricingResult = (
const usd = asFiniteNumber(result.usd)
if (usd === null) return ''
const fmt = { ...defaults, ...(result.format ?? {}) }
if (valueOnly) {
const prefix = fmt.approximate ? '~' : ''
return `${prefix}${formatCreditsValue(usd)}`
}
return formatCreditsLabel(usd, fmt)
}
@@ -310,6 +357,10 @@ const formatPricingResult = (
const maxUsd = asFiniteNumber(result.max_usd)
if (minUsd === null || maxUsd === null) return ''
const fmt = { ...defaults, ...(result.format ?? {}) }
if (valueOnly) {
const prefix = fmt.approximate ? '~' : ''
return `${prefix}${formatCreditsRangeValue(minUsd, maxUsd)}`
}
return formatCreditsRangeLabel(minUsd, maxUsd, fmt)
}
@@ -324,6 +375,10 @@ const formatPricingResult = (
if (usdValues.length === 0) return ''
const fmt = { ...defaults, ...(result.format ?? {}) }
if (valueOnly) {
const prefix = fmt.approximate ? '~' : ''
return `${prefix}${formatCreditsListValue(usdValues)}`
}
return formatCreditsListLabel(usdValues, fmt)
}
@@ -418,8 +473,6 @@ const cache = new WeakMap<LGraphNode, CacheEntry>()
const desiredSig = new WeakMap<LGraphNode, string>()
const inflight = new WeakMap<LGraphNode, InflightEntry>()
const DEBUG_JSONATA_PRICING = false
const scheduleEvaluation = (
node: LGraphNode,
rule: CompiledJsonataPricingRule,
@@ -433,31 +486,17 @@ const scheduleEvaluation = (
if (!rule._compiled) return
const nodeName = getNodeConstructorData(node)?.name ?? ''
const promise = Promise.resolve(rule._compiled.evaluate(ctx))
.then((res) => {
const label = formatPricingResult(res, rule.result_defaults ?? {})
const label = formatPricingResult(res)
// Ignore stale results: if the node changed while we were evaluating,
// desiredSig will no longer match.
if (desiredSig.get(node) !== sig) return
cache.set(node, { sig, label })
if (DEBUG_JSONATA_PRICING) {
console.warn('[pricing/jsonata] resolved', nodeName, {
sig,
res,
label
})
}
})
.catch((err) => {
if (process.env.NODE_ENV === 'development') {
console.warn('[pricing/jsonata] evaluation failed', nodeName, err)
}
.catch(() => {
// Cache empty to avoid retry-spam for same signature
if (desiredSig.get(node) === sig) {
cache.set(node, { sig, label: '' })
@@ -497,6 +536,14 @@ const getRuleForNode = (
return compiled ?? undefined
}
// -----------------------------
// Helper to get price badge from node type
// -----------------------------
const getNodePriceBadge = (nodeType: string): PriceBadge | undefined => {
const nodeDefStore = useNodeDefStore()
return nodeDefStore.nodeDefsByName[nodeType]?.price_badge
}
// -----------------------------
// Public composable API
// -----------------------------
@@ -550,11 +597,7 @@ export const useNodePricing = () => {
* returns union of widget dependencies + input dependencies for a node type.
*/
const getRelevantWidgetNames = (nodeType: string): string[] => {
const nodeDefStore = useNodeDefStore()
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return []
const priceBadge = nodeDef.price_badge
const priceBadge = getNodePriceBadge(nodeType)
if (!priceBadge) return []
const dependsOn = priceBadge.depends_on ?? {
@@ -563,10 +606,9 @@ export const useNodePricing = () => {
input_groups: []
}
// Extract widget names
const widgetNames = (dependsOn.widgets ?? []).map((w) => w.name)
// Keep stable output (dedupe while preserving order)
// Dedupe while preserving order
const out: string[] = []
for (const n of [
...widgetNames,
@@ -582,11 +624,7 @@ export const useNodePricing = () => {
* Check if a node type has dynamic pricing (depends on widgets, inputs, or input_groups).
*/
const hasDynamicPricing = (nodeType: string): boolean => {
const nodeDefStore = useNodeDefStore()
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return false
const priceBadge = nodeDef.price_badge
const priceBadge = getNodePriceBadge(nodeType)
if (!priceBadge) return false
const dependsOn = priceBadge.depends_on
@@ -603,28 +641,16 @@ export const useNodePricing = () => {
* Get input_groups prefixes for a node type (for watching connection changes).
*/
const getInputGroupPrefixes = (nodeType: string): string[] => {
const nodeDefStore = useNodeDefStore()
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return []
const priceBadge = nodeDef.price_badge
if (!priceBadge) return []
return priceBadge.depends_on?.input_groups ?? []
const priceBadge = getNodePriceBadge(nodeType)
return priceBadge?.depends_on?.input_groups ?? []
}
/**
* Get regular input names for a node type (for watching connection changes).
*/
const getInputNames = (nodeType: string): string[] => {
const nodeDefStore = useNodeDefStore()
const nodeDef = nodeDefStore.nodeDefsByName[nodeType]
if (!nodeDef) return []
const priceBadge = nodeDef.price_badge
if (!priceBadge) return []
return priceBadge.depends_on?.inputs ?? []
const priceBadge = getNodePriceBadge(nodeType)
return priceBadge?.depends_on?.inputs ?? []
}
/**
@@ -652,3 +678,97 @@ export const useNodePricing = () => {
pricingRevision: readonly(pricingTick) // reactive invalidation signal
}
}
/**
* Extract default value from an input spec.
*/
function extractDefaultFromSpec(spec: unknown[]): unknown {
const specOptions = spec[1] as Record<string, unknown> | undefined
// Check for explicit default
if (specOptions && 'default' in specOptions) {
return specOptions.default
}
// COMBO/DYNAMICCOMBO type with options array
if (
specOptions &&
Array.isArray(specOptions.options) &&
specOptions.options.length > 0
) {
const firstOption = specOptions.options[0]
// Dynamic combo: options are objects with 'key' property
if (
typeof firstOption === 'object' &&
firstOption !== null &&
'key' in firstOption
) {
return (firstOption as { key: unknown }).key
}
// Standard combo: options are primitive values
return firstOption
}
// COMBO type (old format): [["option1", "option2"], {...}]
if (Array.isArray(spec[0]) && spec[0].length > 0) {
return spec[0][0]
}
return null
}
/**
* Evaluate pricing for a node definition using default widget values.
* Used for NodePricingBadge where no LGraphNode instance exists.
* Results are memoized by node name since they are deterministic.
*/
export const evaluateNodeDefPricing = memoize(
async (nodeDef: ComfyNodeDef): Promise<string> => {
const priceBadge = nodeDef.price_badge
if (!priceBadge?.expr) return ''
// Reuse compiled expression cache
const rule = getCompiledRuleForNodeType(nodeDef.name, priceBadge)
if (!rule?._compiled) return ''
try {
// Merge all inputs for lookup
const allInputs = {
...(nodeDef.input?.required ?? {}),
...(nodeDef.input?.optional ?? {})
}
// Build widgets context using depends_on.widgets (matches buildJsonataContext)
const widgets: Record<string, NormalizedWidgetValue> = {}
for (const dep of priceBadge.depends_on?.widgets ?? []) {
const spec = allInputs[dep.name]
let rawValue: unknown = null
if (Array.isArray(spec)) {
rawValue = extractDefaultFromSpec(spec)
} else if (dep.type.toUpperCase() === 'COMBO') {
// For dynamic COMBO widgets without input spec, use a common default
// that works with most pricing expressions (e.g., resolution selectors)
rawValue = 'original'
}
widgets[dep.name] = normalizeWidgetValue(rawValue, dep.type)
}
// Build inputs context: assume all inputs are disconnected in preview
const inputs: Record<string, { connected: boolean }> = {}
for (const name of priceBadge.depends_on?.inputs ?? []) {
inputs[name] = { connected: false }
}
// Build inputGroups context: assume 0 connected inputs in preview
const inputGroups: Record<string, number> = {}
for (const groupName of priceBadge.depends_on?.input_groups ?? []) {
inputGroups[groupName] = 0
}
const context: JsonataEvalContext = { widgets, inputs, inputGroups }
const result = await rule._compiled.evaluate(context)
return formatPricingResult(result, { valueOnly: true })
} catch (e) {
console.error('[evaluateNodeDefPricing] error:', e)
return ''
}
},
{ getCacheKey: (nodeDef: ComfyNodeDef) => nodeDef.name }
)