Update the frontend to support async nodes. (#4382)

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
This commit is contained in:
guill
2025-07-22 20:46:00 -07:00
committed by GitHub
parent ff68c42162
commit 7eb3eb2473
28 changed files with 1185 additions and 120 deletions

View File

@@ -72,7 +72,6 @@ import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n, t } from '@/i18n'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -192,22 +191,26 @@ watch(
}
)
// Update the progress of the executing node
// Update the progress of executing nodes
watch(
() =>
[
executionStore.executingNodeId,
executionStore.executingNodeProgress
] satisfies [NodeId | null, number | null],
([executingNodeId, executingNodeProgress]) => {
for (const node of comfyApp.graph.nodes) {
if (node.id == executingNodeId) {
node.progress = executingNodeProgress ?? undefined
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
([nodeLocationProgressStates, canvas]) => {
if (!canvas?.graph) return
for (const node of canvas.graph.nodes) {
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(node.id)
const progressState = nodeLocationProgressStates[nodeLocatorId]
if (progressState && progressState.state === 'running') {
node.progress = progressState.value / progressState.max
} else {
node.progress = undefined
}
}
}
// Force canvas redraw to ensure progress updates are visible
canvas.graph.setDirtyCanvas(true, false)
},
{ deep: true }
)
// Update node slot errors

View File

@@ -20,33 +20,44 @@ import { useExecutionStore } from '@/stores/executionStore'
import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel<string>({ required: true })
defineProps<{
const props = defineProps<{
widget?: object
nodeId: NodeId
}>()
const executionStore = useExecutionStore()
const isParentNodeExecuting = ref(true)
const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
let executingNodeId: NodeId | null = null
let parentNodeId: NodeId | null = null
onMounted(() => {
executingNodeId = executionStore.executingNodeId
// Get the parent node ID from props if provided
// For backward compatibility, fall back to the first executing node
parentNodeId = props.nodeId
})
// Watch for either a new node has starting execution or overall execution ending
const stopWatching = watch(
[() => executionStore.executingNode, () => executionStore.isIdle],
[() => executionStore.executingNodeIds, () => executionStore.isIdle],
() => {
if (executionStore.isIdle) {
isParentNodeExecuting.value = false
stopWatching()
return
}
// Check if parent node is no longer in the executing nodes list
if (
executionStore.isIdle ||
(executionStore.executingNode &&
executionStore.executingNode.id !== executingNodeId)
parentNodeId &&
!executionStore.executingNodeIds.includes(parentNodeId)
) {
isParentNodeExecuting.value = false
stopWatching()
}
if (!executingNodeId) {
executingNodeId = executionStore.executingNodeId
// Set parent node ID if not set yet
if (!parentNodeId && executionStore.executingNodeIds.length > 0) {
parentNodeId = executionStore.executingNodeIds[0]
}
}
)

View File

@@ -1,6 +1,7 @@
import { useTitle } from '@vueuse/core'
import { computed } from 'vue'
import { t } from '@/i18n'
import { useExecutionStore } from '@/stores/executionStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
@@ -36,11 +37,34 @@ export const useBrowserTabTitle = () => {
: DEFAULT_TITLE
})
const nodeExecutionTitle = computed(() =>
executionStore.executingNode && executionStore.executingNodeProgress
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
: ''
)
const nodeExecutionTitle = computed(() => {
// Check if any nodes are in progress
const nodeProgressEntries = Object.entries(
executionStore.nodeProgressStates
)
const runningNodes = nodeProgressEntries.filter(
([_, state]) => state.state === 'running'
)
if (runningNodes.length === 0) {
return ''
}
// If multiple nodes are running
if (runningNodes.length > 1) {
return `${executionText.value}[${runningNodes.length} ${t('g.nodesRunning', 'nodes running')}]`
}
// If only one node is running
const [nodeId, state] = runningNodes[0]
const progress = Math.round((state.value / state.max) * 100)
const nodeType =
executionStore.activePrompt?.workflow?.changeTracker?.activeState.nodes.find(
(n) => String(n.id) === nodeId
)?.type || 'Node'
return `${executionText.value}[${progress}%] ${nodeType}`
})
const workflowTitle = computed(
() =>

View File

@@ -3,14 +3,23 @@ import { ref } from 'vue'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type ChatHistoryCustomProps = Omit<
InstanceType<typeof ChatHistoryWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useChatHistoryWidget = (
options: {
props?: Omit<InstanceType<typeof ChatHistoryWidget>['$props'], 'widget'>
props?: ChatHistoryCustomProps
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
@@ -20,7 +29,7 @@ export const useChatHistoryWidget = (
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
InstanceType<typeof ChatHistoryWidget>['$props']
ChatHistoryCustomProps
>({
node,
name: inputSpec.name,

View File

@@ -3,9 +3,18 @@ import { ref } from 'vue'
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useTextPreviewWidget = (
@@ -18,11 +27,17 @@ export const useTextPreviewWidget = (
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<string | object>({
const widget = new ComponentWidgetImpl<
string | object,
TextPreviewCustomProps
>({
node,
name: inputSpec.name,
component: TextPreviewWidget,
inputSpec,
props: {
nodeId: node.id
},
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {

View File

@@ -1,3 +1,3 @@
{
"supports_preview_metadata": false
"supports_preview_metadata": true
}

View File

@@ -15,6 +15,7 @@ import {
} from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useDialogService } from '@/services/dialogService'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
@@ -1224,9 +1225,10 @@ export class GroupNodeHandler {
node.onDrawForeground = function (ctx) {
// @ts-expect-error fixme ts strict error
onDrawForeground?.apply?.(this, arguments)
const progressState = useExecutionStore().nodeProgressStates[this.id]
if (
// @ts-expect-error fixme ts strict error
+app.runningNodeId === this.id &&
progressState &&
progressState.state === 'running' &&
this.runningInternalNodeId !== null
) {
// @ts-expect-error fixme ts strict error
@@ -1340,6 +1342,7 @@ export class GroupNodeHandler {
this.node.onRemoved = function () {
// @ts-expect-error fixme ts strict error
onRemoved?.apply(this, arguments)
// api.removeEventListener('progress_state', progress_state)
api.removeEventListener('executing', executing)
api.removeEventListener('executed', executed)
}

View File

@@ -133,7 +133,8 @@
"copyURL": "Copy URL",
"releaseTitle": "{package} {version} Release",
"progressCountOf": "of",
"keybindingAlreadyExists": "Keybinding already exists on"
"keybindingAlreadyExists": "Keybinding already exists on",
"nodesRunning": "nodes running"
},
"manager": {
"title": "Custom Nodes Manager",

View File

@@ -336,6 +336,7 @@
"noTasksFoundMessage": "No hay tareas en la cola.",
"noWorkflowsFound": "No se encontraron flujos de trabajo.",
"nodes": "Nodos",
"nodesRunning": "nodos en ejecución",
"ok": "OK",
"openNewIssue": "Abrir nuevo problema",
"overwrite": "Sobrescribir",

View File

@@ -336,6 +336,7 @@
"noTasksFoundMessage": "Il n'y a pas de tâches dans la file d'attente.",
"noWorkflowsFound": "Aucun flux de travail trouvé.",
"nodes": "Nœuds",
"nodesRunning": "nœuds en cours dexécution",
"ok": "OK",
"openNewIssue": "Ouvrir un nouveau problème",
"overwrite": "Écraser",

View File

@@ -336,6 +336,7 @@
"noTasksFoundMessage": "キューにタスクがありません。",
"noWorkflowsFound": "ワークフローが見つかりません。",
"nodes": "ノード",
"nodesRunning": "ノードが実行中",
"ok": "OK",
"openNewIssue": "新しい問題を開く",
"overwrite": "上書き",

View File

@@ -336,6 +336,7 @@
"noTasksFoundMessage": "대기열에 작업이 없습니다.",
"noWorkflowsFound": "워크플로를 찾을 수 없습니다.",
"nodes": "노드",
"nodesRunning": "노드 실행 중",
"ok": "확인",
"openNewIssue": "새 문제 열기",
"overwrite": "덮어쓰기",

View File

@@ -336,6 +336,7 @@
"noTasksFoundMessage": "В очереди нет задач.",
"noWorkflowsFound": "Рабочие процессы не найдены.",
"nodes": "Узлы",
"nodesRunning": "запущено узлов",
"ok": "ОК",
"openNewIssue": "Открыть новую проблему",
"overwrite": "Перезаписать",

View File

@@ -336,6 +336,7 @@
"noTasksFoundMessage": "佇列中沒有任務。",
"noWorkflowsFound": "找不到工作流程。",
"nodes": "節點",
"nodesRunning": "節點執行中",
"ok": "確定",
"openNewIssue": "開啟新問題",
"overwrite": "覆蓋",

View File

@@ -336,6 +336,7 @@
"noTasksFoundMessage": "队列中没有任务。",
"noWorkflowsFound": "未找到工作流。",
"nodes": "节点",
"nodesRunning": "节点正在运行",
"ok": "确定",
"openNewIssue": "打开新问题",
"overwrite": "覆盖",
@@ -785,13 +786,13 @@
"Toggle Bottom Panel": "切换底部面板",
"Toggle Focus Mode": "切换专注模式",
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Model Library Sidebar": "切模型库侧边栏",
"Toggle Node Library Sidebar": "切换节点库侧边栏",
"Toggle Queue Sidebar": "切换队列侧边栏",
"Toggle Model Library Sidebar": "切模型庫側邊欄",
"Toggle Node Library Sidebar": "切換節點庫側邊欄",
"Toggle Queue Sidebar": "切換佇列側邊欄",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle Workflows Sidebar": "切工作流侧边栏",
"Toggle Workflows Sidebar": "切工作流程側邊欄",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",

View File

@@ -48,6 +48,22 @@ const zProgressWsMessage = z.object({
node: zNodeId
})
const zNodeProgressState = z.object({
value: z.number(),
max: z.number(),
state: z.enum(['pending', 'running', 'finished', 'error']),
node_id: zNodeId,
prompt_id: zPromptId,
display_node_id: zNodeId.optional(),
parent_node_id: zNodeId.optional(),
real_node_id: zNodeId.optional()
})
const zProgressStateWsMessage = z.object({
prompt_id: zPromptId,
nodes: z.record(zNodeId, zNodeProgressState)
})
const zExecutingWsMessage = z.object({
node: zNodeId,
display_node: zNodeId,
@@ -134,6 +150,8 @@ export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
export type DisplayComponentWsMessage = z.infer<
typeof zDisplayComponentWsMessage
>
export type NodeProgressState = z.infer<typeof zNodeProgressState>
export type ProgressStateWsMessage = z.infer<typeof zProgressStateWsMessage>
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
// End of ws messages

View File

@@ -17,6 +17,7 @@ import type {
LogsRawResponse,
LogsWsMessage,
PendingTaskItem,
ProgressStateWsMessage,
ProgressTextWsMessage,
ProgressWsMessage,
PromptResponse,
@@ -105,7 +106,17 @@ interface BackendApiCalls {
logs: LogsWsMessage
/** Binary preview/progress data */
b_preview: Blob
/** Binary preview with metadata (node_id, prompt_id) */
b_preview_with_metadata: {
blob: Blob
nodeId: string
parentNodeId: string
displayNodeId: string
realNodeId: string
promptId: string
}
progress_text: ProgressTextWsMessage
progress_state: ProgressStateWsMessage
display_component: DisplayComponentWsMessage
feature_flags: FeatureFlagsWsMessage
}
@@ -457,6 +468,33 @@ export class ComfyApi extends EventTarget {
})
this.dispatchCustomEvent('b_preview', imageBlob)
break
case 4:
// PREVIEW_IMAGE_WITH_METADATA
const decoder4 = new TextDecoder()
const metadataLength = view.getUint32(4)
const metadataBytes = event.data.slice(8, 8 + metadataLength)
const metadata = JSON.parse(decoder4.decode(metadataBytes))
const imageData4 = event.data.slice(8 + metadataLength)
let imageMime4 = metadata.image_type
const imageBlob4 = new Blob([imageData4], {
type: imageMime4
})
// Dispatch enhanced preview event with metadata
this.dispatchCustomEvent('b_preview_with_metadata', {
blob: imageBlob4,
nodeId: metadata.node_id,
displayNodeId: metadata.display_node_id,
parentNodeId: metadata.parent_node_id,
realNodeId: metadata.real_node_id,
promptId: metadata.prompt_id
})
// Also dispatch legacy b_preview for backward compatibility
this.dispatchCustomEvent('b_preview', imageBlob4)
break
default:
throw new Error(
`Unknown binary websocket message of type ${eventType}`
@@ -486,6 +524,7 @@ export class ComfyApi extends EventTarget {
case 'execution_cached':
case 'execution_success':
case 'progress':
case 'progress_state':
case 'executed':
case 'graphChanged':
case 'promptQueued':

View File

@@ -194,6 +194,8 @@ export class ComfyApp {
/**
* @deprecated Use useExecutionStore().executingNodeId instead
* TODO: Update to support multiple executing nodes. This getter returns only the first executing node.
* Consider updating consumers to handle multiple nodes or use executingNodeIds array.
*/
get runningNodeId(): NodeId | null {
return useExecutionStore().executingNodeId
@@ -635,10 +637,6 @@ export class ComfyApp {
api.addEventListener('executing', () => {
this.graph.setDirtyCanvas(true, false)
// @ts-expect-error fixme ts strict error
this.revokePreviews(this.runningNodeId)
// @ts-expect-error fixme ts strict error
delete this.nodePreviewImages[this.runningNodeId]
})
api.addEventListener('executed', ({ detail }) => {
@@ -689,15 +687,13 @@ export class ComfyApp {
this.canvas.draw(true, true)
})
api.addEventListener('b_preview', ({ detail }) => {
const id = this.runningNodeId
if (id == null) return
const blob = detail
api.addEventListener('b_preview_with_metadata', ({ detail }) => {
// Enhanced preview with explicit node context
const { blob, displayNodeId } = detail
this.revokePreviews(displayNodeId)
const blobUrl = URL.createObjectURL(blob)
// Ensure clean up if `executing` event is missed.
this.revokePreviews(id)
this.nodePreviewImages[id] = [blobUrl]
// Preview cleanup is now handled in progress_state event to support multiple concurrent previews
this.nodePreviewImages[displayNodeId] = [blobUrl]
})
api.init()

View File

@@ -44,12 +44,30 @@ export interface DOMWidget<T extends HTMLElement, V extends object | string>
inputEl?: T
}
/**
* Additional props that can be passed to component widgets.
* These are in addition to the standard props that are always provided:
* - modelValue: The widget's value (handled by v-model)
* - widget: Reference to the widget instance
* - onUpdate:modelValue: The update handler for v-model
*/
export type ComponentWidgetCustomProps = Record<string, unknown>
/**
* Standard props that are handled separately by DomWidget.vue and should be
* omitted when defining custom props for component widgets
*/
export type ComponentWidgetStandardProps =
| 'modelValue'
| 'widget'
| 'onUpdate:modelValue'
/**
* A DOM widget that wraps a Vue component as a litegraph widget.
*/
export interface ComponentWidget<
V extends object | string,
P = Record<string, unknown>
P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps
> extends BaseDOMWidget<V> {
readonly component: Component
readonly inputSpec: InputSpec
@@ -247,7 +265,7 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
export class ComponentWidgetImpl<
V extends object | string,
P = Record<string, unknown>
P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps
>
extends BaseDOMWidgetImpl<V>
implements ComponentWidget<V, P>

View File

@@ -35,6 +35,7 @@ import { ComfyApp, app } from '@/scripts/app'
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
import { $el } from '@/scripts/ui'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
@@ -140,7 +141,11 @@ export const useLitegraphService = () => {
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
if (this.id == app.runningNodeId) {
const nodeId = String(this.id)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
const state =
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
if (state === 'running') {
return { color: '#0f0' }
}
}
@@ -395,7 +400,11 @@ export const useLitegraphService = () => {
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
if (this.id == app.runningNodeId) {
const nodeId = String(this.id)
const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId)
const state =
useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state
if (state === 'running') {
return { color: '#0f0' }
}
}

View File

@@ -12,6 +12,8 @@ import type {
ExecutionErrorWsMessage,
ExecutionStartWsMessage,
NodeError,
NodeProgressState,
ProgressStateWsMessage,
ProgressTextWsMessage,
ProgressWsMessage
} from '@/schemas/apiSchema'
@@ -21,6 +23,9 @@ import type {
NodeId
} from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { useCanvasStore } from './graphStore'
import { ComfyWorkflow, useWorkflowStore } from './workflowStore'
@@ -46,7 +51,97 @@ export const useExecutionStore = defineStore('execution', () => {
const queuedPrompts = ref<Record<NodeId, QueuedPrompt>>({})
const lastNodeErrors = ref<Record<NodeId, NodeError> | null>(null)
const lastExecutionError = ref<ExecutionErrorWsMessage | null>(null)
const executingNodeId = ref<NodeId | null>(null)
// This is the progress of all nodes in the currently executing workflow
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
/**
* Convert execution context node IDs to NodeLocatorIds
* @param nodeId The node ID from execution context (could be execution ID)
* @returns The NodeLocatorId
*/
const executionIdToNodeLocatorId = (
nodeId: string | number
): NodeLocatorId => {
const nodeIdStr = String(nodeId)
if (!nodeIdStr.includes(':')) {
// It's a top-level node ID
return nodeIdStr
}
// It's an execution node ID
const parts = nodeIdStr.split(':')
const localNodeId = parts[parts.length - 1]
const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts)
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
return nodeLocatorId
}
const mergeExecutionProgressStates = (
currentState: NodeProgressState | undefined,
newState: NodeProgressState
): NodeProgressState => {
if (currentState === undefined) {
return newState
}
const mergedState = { ...currentState }
if (mergedState.state === 'error') {
return mergedState
} else if (newState.state === 'running') {
const newPerc = newState.max > 0 ? newState.value / newState.max : 0.0
const oldPerc =
mergedState.max > 0 ? mergedState.value / mergedState.max : 0.0
if (
mergedState.state !== 'running' ||
oldPerc === 0.0 ||
newPerc < oldPerc
) {
mergedState.value = newState.value
mergedState.max = newState.max
}
mergedState.state = 'running'
}
return mergedState
}
const nodeLocationProgressStates = computed<
Record<NodeLocatorId, NodeProgressState>
>(() => {
const result: Record<NodeLocatorId, NodeProgressState> = {}
const states = nodeProgressStates.value // Apparently doing this inside `Object.entries` causes issues
for (const state of Object.values(states)) {
const parts = String(state.display_node_id).split(':')
for (let i = 0; i < parts.length; i++) {
const executionId = parts.slice(0, i + 1).join(':')
const locatorId = executionIdToNodeLocatorId(executionId)
if (!locatorId) continue
result[locatorId] = mergeExecutionProgressStates(
result[locatorId],
state
)
}
}
return result
})
// Easily access all currently executing node IDs
const executingNodeIds = computed<NodeId[]>(() => {
return Object.entries(nodeProgressStates)
.filter(([_, state]) => state.state === 'running')
.map(([nodeId, _]) => nodeId)
})
// @deprecated For backward compatibility - stores the primary executing node ID
const executingNodeId = computed<NodeId | null>(() => {
return executingNodeIds.value.length > 0 ? executingNodeIds.value[0] : null
})
// For backward compatibility - returns the primary executing node
const executingNode = computed<ComfyNode | null>(() => {
if (!executingNodeId.value) return null
@@ -93,30 +188,7 @@ export const useExecutionStore = defineStore('execution', () => {
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
}
const executionIdToCurrentId = (id: string) => {
const subgraph = workflowStore.activeSubgraph
// Short-circuit: ID belongs to the parent workflow / no active subgraph
if (!id.includes(':')) {
return !subgraph ? id : undefined
} else if (!subgraph) {
return
}
// Parse the hierarchical ID (e.g., "123:456:789")
const subgraphNodeIds = id.split(':')
// If the last subgraph is the active subgraph, return the node ID
const subgraphs = getSubgraphsFromInstanceIds(
subgraph.rootGraph,
subgraphNodeIds
)
if (subgraphs.at(-1) === subgraph) {
return subgraphNodeIds.at(-1)
}
}
// This is the progress of the currently executing node, if any
// This is the progress of the currently executing node (for backward compatibility)
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
const executingNodeProgress = computed(() =>
_executingNodeProgress.value
@@ -153,6 +225,7 @@ export const useExecutionStore = defineStore('execution', () => {
api.addEventListener('executed', handleExecuted)
api.addEventListener('executing', handleExecuting)
api.addEventListener('progress', handleProgress)
api.addEventListener('progress_state', handleProgressState)
api.addEventListener('status', handleStatus)
api.addEventListener('execution_error', handleExecutionError)
}
@@ -165,6 +238,7 @@ export const useExecutionStore = defineStore('execution', () => {
api.removeEventListener('executed', handleExecuted)
api.removeEventListener('executing', handleExecuting)
api.removeEventListener('progress', handleProgress)
api.removeEventListener('progress_state', handleProgressState)
api.removeEventListener('status', handleStatus)
api.removeEventListener('execution_error', handleExecutionError)
api.removeEventListener('progress_text', handleProgressText)
@@ -194,19 +268,42 @@ export const useExecutionStore = defineStore('execution', () => {
if (!activePrompt.value) return
if (executingNodeId.value && activePrompt.value) {
// Seems sometimes nodes that are cached fire executing but not executed
activePrompt.value.nodes[executingNodeId.value] = true
// Update the executing nodes list
if (typeof e.detail !== 'string') {
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]
}
activePromptId.value = null
}
if (typeof e.detail === 'string') {
executingNodeId.value = executionIdToCurrentId(e.detail) ?? null
} else {
executingNodeId.value = e.detail
if (executingNodeId.value === null) {
if (activePromptId.value) {
delete queuedPrompts.value[activePromptId.value]
}
activePromptId.value = null
}
function handleProgressState(e: CustomEvent<ProgressStateWsMessage>) {
const { nodes } = e.detail
// Revoke previews for nodes that are starting to execute
for (const nodeId in nodes) {
const nodeState = nodes[nodeId]
if (nodeState.state === 'running' && !nodeProgressStates.value[nodeId]) {
// This node just started executing, revoke its previews
// Note that we're doing the *actual* node id instead of the display node id
// here intentionally. That way, we don't clear the preview every time a new node
// within an expanded graph starts executing.
app.revokePreviews(nodeId)
delete app.nodePreviewImages[nodeId]
}
}
// Update the progress states for all nodes
nodeProgressStates.value = nodes
// If we have progress for the currently executing node, update it for backwards compatibility
if (executingNodeId.value && nodes[executingNodeId.value]) {
const nodeState = nodes[executingNodeId.value]
_executingNodeProgress.value = {
value: nodeState.value,
max: nodeState.max,
prompt_id: nodeState.prompt_id,
node: nodeState.display_node_id || nodeState.node_id
}
}
}
@@ -239,7 +336,7 @@ export const useExecutionStore = defineStore('execution', () => {
const { nodeId, text } = e.detail
if (!text || !nodeId) return
// Handle hierarchical node IDs for subgraphs
// Handle execution node IDs for subgraphs
const currentId = getNodeIdIfExecuting(nodeId)
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
if (!node) return
@@ -250,7 +347,7 @@ export const useExecutionStore = defineStore('execution', () => {
function handleDisplayComponent(e: CustomEvent<DisplayComponentWsMessage>) {
const { node_id: nodeId, component, props = {} } = e.detail
// Handle hierarchical node IDs for subgraphs
// Handle execution node IDs for subgraphs
const currentId = getNodeIdIfExecuting(nodeId)
const node = canvasStore.getCanvas().graph?.getNodeById(currentId)
if (!node) return
@@ -290,6 +387,18 @@ export const useExecutionStore = defineStore('execution', () => {
)
}
/**
* Convert a NodeLocatorId to an execution context ID
* @param locatorId The NodeLocatorId
* @returns The execution ID or null if conversion fails
*/
const nodeLocatorIdToExecutionId = (
locatorId: NodeLocatorId | string
): string | null => {
const executionId = workflowStore.nodeLocatorIdToNodeExecutionId(locatorId)
return executionId
}
return {
isIdle,
clientId,
@@ -310,9 +419,13 @@ export const useExecutionStore = defineStore('execution', () => {
*/
lastExecutionError,
/**
* The id of the node that is currently being executed
* The id of the node that is currently being executed (backward compatibility)
*/
executingNodeId,
/**
* The list of all nodes that are currently executing
*/
executingNodeIds,
/**
* The prompt that is currently being executed
*/
@@ -330,17 +443,25 @@ export const useExecutionStore = defineStore('execution', () => {
*/
executionProgress,
/**
* The node that is currently being executed
* The node that is currently being executed (backward compatibility)
*/
executingNode,
/**
* The progress of the executing node (if the node reports progress)
* The progress of the executing node (backward compatibility)
*/
executingNodeProgress,
/**
* All node progress states from progress_state events
*/
nodeProgressStates,
nodeLocationProgressStates,
bindExecutionEvents,
unbindExecutionEvents,
storePrompt,
// Raw executing progress data for backward compatibility in ComfyApp.
_executingNodeProgress
_executingNodeProgress,
// NodeLocatorId conversion helpers
executionIdToNodeLocatorId,
nodeLocatorIdToExecutionId
}
})

View File

@@ -4,10 +4,18 @@ import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { defaultGraphJSON } from '@/scripts/defaultGraph'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import {
createNodeExecutionId,
createNodeLocatorId,
parseNodeExecutionId,
parseNodeLocatorId
} from '@/types/nodeIdentification'
import { getPathDetails } from '@/utils/formatUtil'
import { syncEntities } from '@/utils/syncUtil'
import { isSubgraph } from '@/utils/typeGuardUtil'
@@ -163,6 +171,15 @@ export interface WorkflowStore {
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
updateActiveGraph: () => void
executionIdToCurrentId: (id: string) => any
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
nodeExecutionIdToNodeLocatorId: (
nodeExecutionId: NodeExecutionId | string
) => NodeLocatorId | null
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null
nodeLocatorIdToNodeExecutionId: (
locatorId: NodeLocatorId | string,
targetSubgraph?: Subgraph
) => NodeExecutionId | null
}
export const useWorkflowStore = defineStore('workflow', () => {
@@ -473,7 +490,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
return
}
// Parse the hierarchical ID (e.g., "123:456:789")
// Parse the execution ID (e.g., "123:456:789")
const subgraphNodeIds = id.split(':')
// Start from the root graph
@@ -488,6 +505,136 @@ export const useWorkflowStore = defineStore('workflow', () => {
watch(activeWorkflow, updateActiveGraph)
/**
* Convert a node ID to a NodeLocatorId
* @param nodeId The local node ID
* @param subgraph The subgraph containing the node (defaults to active subgraph)
* @returns The NodeLocatorId (for root graph nodes, returns the node ID as-is)
*/
const nodeIdToNodeLocatorId = (
nodeId: NodeId,
subgraph?: Subgraph
): NodeLocatorId => {
const targetSubgraph = subgraph ?? activeSubgraph.value
if (!targetSubgraph) {
// Node is in the root graph, return the node ID as-is
return String(nodeId)
}
return createNodeLocatorId(targetSubgraph.id, nodeId)
}
/**
* Convert an execution ID to a NodeLocatorId
* @param nodeExecutionId The execution node ID (e.g., "123:456:789")
* @returns The NodeLocatorId or null if conversion fails
*/
const nodeExecutionIdToNodeLocatorId = (
nodeExecutionId: NodeExecutionId | string
): NodeLocatorId | null => {
// Handle simple node IDs (root graph - no colons)
if (!nodeExecutionId.includes(':')) {
return nodeExecutionId
}
const parts = parseNodeExecutionId(nodeExecutionId)
if (!parts || parts.length === 0) return null
const nodeId = parts[parts.length - 1]
const subgraphNodeIds = parts.slice(0, -1)
if (subgraphNodeIds.length === 0) {
// Node is in root graph, return the node ID as-is
return String(nodeId)
}
try {
const subgraphs = getSubgraphsFromInstanceIds(
comfyApp.graph,
subgraphNodeIds.map((id) => String(id))
)
const immediateSubgraph = subgraphs[subgraphs.length - 1]
return createNodeLocatorId(immediateSubgraph.id, nodeId)
} catch {
return null
}
}
/**
* Extract the node ID from a NodeLocatorId
* @param locatorId The NodeLocatorId
* @returns The local node ID or null if invalid
*/
const nodeLocatorIdToNodeId = (
locatorId: NodeLocatorId | string
): NodeId | null => {
const parsed = parseNodeLocatorId(locatorId)
return parsed?.localNodeId ?? null
}
/**
* Convert a NodeLocatorId to an execution ID for a specific context
* @param locatorId The NodeLocatorId
* @param targetSubgraph The subgraph context (defaults to active subgraph)
* @returns The execution ID or null if the node is not accessible from the target context
*/
const nodeLocatorIdToNodeExecutionId = (
locatorId: NodeLocatorId | string,
targetSubgraph?: Subgraph
): NodeExecutionId | null => {
const parsed = parseNodeLocatorId(locatorId)
if (!parsed) return null
const { subgraphUuid, localNodeId } = parsed
// If no subgraph UUID, this is a root graph node
if (!subgraphUuid) {
return String(localNodeId)
}
// Find the path from root to the subgraph with this UUID
const findSubgraphPath = (
graph: LGraph | Subgraph,
targetUuid: string,
path: NodeId[] = []
): NodeId[] | null => {
if (isSubgraph(graph) && graph.id === targetUuid) {
return path
}
for (const node of graph._nodes) {
if (node.isSubgraphNode() && node.subgraph) {
const result = findSubgraphPath(node.subgraph, targetUuid, [
...path,
node.id
])
if (result) return result
}
}
return null
}
const path = findSubgraphPath(comfyApp.graph, subgraphUuid)
if (!path) return null
// If we have a target subgraph, check if the path goes through it
if (
targetSubgraph &&
!path.some((_, idx) => {
const subgraphs = getSubgraphsFromInstanceIds(
comfyApp.graph,
path.slice(0, idx + 1).map((id) => String(id))
)
return subgraphs[subgraphs.length - 1] === targetSubgraph
})
) {
return null
}
return createNodeExecutionId([...path, localNodeId])
}
return {
activeWorkflow,
isActive,
@@ -514,7 +661,11 @@ export const useWorkflowStore = defineStore('workflow', () => {
isSubgraphActive,
activeSubgraph,
updateActiveGraph,
executionIdToCurrentId
executionIdToCurrentId,
nodeIdToNodeLocatorId,
nodeExecutionIdToNodeLocatorId,
nodeLocatorIdToNodeId,
nodeLocatorIdToNodeExecutionId
}
}) satisfies () => WorkflowStore

View File

@@ -31,6 +31,16 @@ export type { ComfyApi } from '@/scripts/api'
export type { ComfyApp } from '@/scripts/app'
export type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
export type { InputSpec } from '@/schemas/nodeDefSchema'
export type {
NodeLocatorId,
NodeExecutionId,
isNodeLocatorId,
isNodeExecutionId,
parseNodeLocatorId,
createNodeLocatorId,
parseNodeExecutionId,
createNodeExecutionId
} from './nodeIdentification'
export type {
EmbeddingsResponse,
ExtensionsResponse,

View File

@@ -0,0 +1,123 @@
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
/**
* A globally unique identifier for nodes that maintains consistency across
* multiple instances of the same subgraph.
*
* Format:
* - For subgraph nodes: `<immediate-contained-subgraph-uuid>:<local-node-id>`
* - For root graph nodes: `<local-node-id>`
*
* Examples:
* - "a1b2c3d4-e5f6-7890-abcd-ef1234567890:123" (node in subgraph)
* - "456" (node in root graph)
*
* Unlike execution IDs which change based on the instance path,
* NodeLocatorId remains the same for all instances of a particular node.
*/
export type NodeLocatorId = string
/**
* An execution identifier representing a node's position in nested subgraphs.
* Also known as ExecutionId in some contexts.
*
* Format: Colon-separated path of node IDs
* Example: "123:456:789" (node 789 in subgraph 456 in subgraph 123)
*/
export type NodeExecutionId = string
/**
* Type guard to check if a value is a NodeLocatorId
*/
export function isNodeLocatorId(value: unknown): value is NodeLocatorId {
if (typeof value !== 'string') return false
// Check if it's a simple node ID (root graph node)
const parts = value.split(':')
if (parts.length === 1) {
// Simple node ID - must be non-empty
return value.length > 0
}
// Check for UUID:nodeId format
if (parts.length !== 2) return false
// Check that node ID part is not empty
if (!parts[1]) return false
// Basic UUID format check (8-4-4-4-12 hex characters)
const uuidPattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
return uuidPattern.test(parts[0])
}
/**
* Type guard to check if a value is a NodeExecutionId
*/
export function isNodeExecutionId(value: unknown): value is NodeExecutionId {
if (typeof value !== 'string') return false
// Must contain at least one colon to be an execution ID
return value.includes(':')
}
/**
* Parse a NodeLocatorId into its components
* @param id The NodeLocatorId to parse
* @returns The subgraph UUID and local node ID, or null if invalid
*/
export function parseNodeLocatorId(
id: string
): { subgraphUuid: string | null; localNodeId: NodeId } | null {
if (!isNodeLocatorId(id)) return null
const parts = id.split(':')
if (parts.length === 1) {
// Simple node ID (root graph)
return {
subgraphUuid: null,
localNodeId: isNaN(Number(id)) ? id : Number(id)
}
}
const [subgraphUuid, localNodeId] = parts
return {
subgraphUuid,
localNodeId: isNaN(Number(localNodeId)) ? localNodeId : Number(localNodeId)
}
}
/**
* Create a NodeLocatorId from components
* @param subgraphUuid The UUID of the immediate containing subgraph
* @param localNodeId The local node ID within that subgraph
* @returns A properly formatted NodeLocatorId
*/
export function createNodeLocatorId(
subgraphUuid: string,
localNodeId: NodeId
): NodeLocatorId {
return `${subgraphUuid}:${localNodeId}`
}
/**
* Parse a NodeExecutionId into its component node IDs
* @param id The NodeExecutionId to parse
* @returns Array of node IDs from root to target, or null if not an execution ID
*/
export function parseNodeExecutionId(id: string): NodeId[] | null {
if (!isNodeExecutionId(id)) return null
return id
.split(':')
.map((part) => (isNaN(Number(part)) ? part : Number(part)))
}
/**
* Create a NodeExecutionId from an array of node IDs
* @param nodeIds Array of node IDs from root to target
* @returns A properly formatted NodeExecutionId
*/
export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId {
return nodeIds.join(':')
}

View File

@@ -3,12 +3,20 @@ import { nextTick, reactive } from 'vue'
import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle'
// Mock i18n module
vi.mock('@/i18n', () => ({
t: (key: string, fallback: string) =>
key === 'g.nodesRunning' ? 'nodes running' : fallback
}))
// Mock the execution store
const executionStore = reactive({
isIdle: true,
executionProgress: 0,
executingNode: null as any,
executingNodeProgress: 0
executingNodeProgress: 0,
nodeProgressStates: {} as any,
activePrompt: null as any
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStore
@@ -37,6 +45,8 @@ describe('useBrowserTabTitle', () => {
executionStore.executionProgress = 0
executionStore.executingNode = null as any
executionStore.executingNodeProgress = 0
executionStore.nodeProgressStates = {}
executionStore.activePrompt = null
// reset setting and workflow stores
;(settingStore.get as any).mockReturnValue('Enabled')
@@ -97,13 +107,41 @@ describe('useBrowserTabTitle', () => {
expect(document.title).toBe('[30%]ComfyUI')
})
it('shows node execution title when executing a node', async () => {
it('shows node execution title when executing a node using nodeProgressStates', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.4
executionStore.executingNodeProgress = 0.5
executionStore.executingNode = { type: 'Foo' }
executionStore.nodeProgressStates = {
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
}
executionStore.activePrompt = {
workflow: {
changeTracker: {
activeState: {
nodes: [{ id: 1, type: 'Foo' }]
}
}
}
}
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('[40%][50%] Foo')
})
it('shows multiple nodes running when multiple nodes are executing', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.4
executionStore.nodeProgressStates = {
'1': {
state: 'running',
value: 5,
max: 10,
node: '1',
prompt_id: 'test'
},
'2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' }
}
useBrowserTabTitle()
await nextTick()
expect(document.title).toBe('[40%][2 nodes running]')
})
})

View File

@@ -1,11 +1,22 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useWorkflowStore } from '@/stores/workflowStore'
// Mock the workflowStore
vi.mock('@/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({
nodeExecutionIdToNodeLocatorId: vi.fn(),
nodeIdToNodeLocatorId: vi.fn(),
nodeLocatorIdToNodeExecutionId: vi.fn()
}))
}))
// Remove any previous global types
declare global {
// Empty interface to override any previous declarations
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface Window {}
}
@@ -22,12 +33,16 @@ vi.mock('@/composables/node/useNodeProgressText', () => ({
})
}))
// Create a local mock instead of using global to avoid conflicts
const mockApp = {
graph: {
getNodeById: vi.fn()
// Mock the app import with proper implementation
vi.mock('@/scripts/app', () => ({
app: {
graph: {
getNodeById: vi.fn()
},
revokePreviews: vi.fn(),
nodePreviewImages: {}
}
}
}))
describe('executionStore - display_component handling', () => {
function createDisplayComponentEvent(
@@ -47,7 +62,7 @@ describe('executionStore - display_component handling', () => {
function handleDisplayComponentMessage(event: CustomEvent) {
const { node_id, component } = event.detail
const node = mockApp.graph.getNodeById(node_id)
const node = vi.mocked(app.graph.getNodeById)(node_id)
if (node && component === 'ChatHistoryWidget') {
mockShowChatHistory(node)
}
@@ -60,23 +75,121 @@ describe('executionStore - display_component handling', () => {
})
it('handles ChatHistoryWidget display_component messages', () => {
const mockNode = { id: '123' }
mockApp.graph.getNodeById.mockReturnValue(mockNode)
const mockNode = { id: '123' } as any
vi.mocked(app.graph.getNodeById).mockReturnValue(mockNode)
const event = createDisplayComponentEvent('123')
handleDisplayComponentMessage(event)
expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('123')
expect(app.graph.getNodeById).toHaveBeenCalledWith('123')
expect(mockShowChatHistory).toHaveBeenCalledWith(mockNode)
})
it('does nothing if node is not found', () => {
mockApp.graph.getNodeById.mockReturnValue(null)
vi.mocked(app.graph.getNodeById).mockReturnValue(null)
const event = createDisplayComponentEvent('non-existent')
handleDisplayComponentMessage(event)
expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('non-existent')
expect(app.graph.getNodeById).toHaveBeenCalledWith('non-existent')
expect(mockShowChatHistory).not.toHaveBeenCalled()
})
})
describe('useExecutionStore - NodeLocatorId conversions', () => {
let store: ReturnType<typeof useExecutionStore>
let workflowStore: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
setActivePinia(createPinia())
// Create the mock workflowStore instance
const mockWorkflowStore = {
nodeExecutionIdToNodeLocatorId: vi.fn(),
nodeIdToNodeLocatorId: vi.fn(),
nodeLocatorIdToNodeExecutionId: vi.fn()
}
// Mock the useWorkflowStore function to return our mock
vi.mocked(useWorkflowStore).mockReturnValue(mockWorkflowStore as any)
workflowStore = mockWorkflowStore as any
store = useExecutionStore()
vi.clearAllMocks()
})
describe('executionIdToNodeLocatorId', () => {
it('should convert execution ID to NodeLocatorId', () => {
// Mock subgraph structure
const mockSubgraph = {
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
_nodes: []
}
const mockNode = {
id: 123,
isSubgraphNode: () => true,
subgraph: mockSubgraph
} as any
// Mock app.graph.getNodeById to return the mock node
vi.mocked(app.graph.getNodeById).mockReturnValue(mockNode)
const result = store.executionIdToNodeLocatorId('123:456')
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
})
it('should convert simple node ID to NodeLocatorId', () => {
const result = store.executionIdToNodeLocatorId('123')
// For simple node IDs, it should return the ID as-is
expect(result).toBe('123')
})
it('should handle numeric node IDs', () => {
const result = store.executionIdToNodeLocatorId(123)
// For numeric IDs, it should convert to string and return as-is
expect(result).toBe('123')
})
it('should return null when conversion fails', () => {
// Mock app.graph.getNodeById to return null (node not found)
vi.mocked(app.graph.getNodeById).mockReturnValue(null)
// This should throw an error as the node is not found
expect(() => store.executionIdToNodeLocatorId('999:456')).toThrow(
'Subgraph not found: 999'
)
})
})
describe('nodeLocatorIdToExecutionId', () => {
it('should convert NodeLocatorId to execution ID', () => {
const mockExecutionId = '123:456'
vi.spyOn(workflowStore, 'nodeLocatorIdToNodeExecutionId').mockReturnValue(
mockExecutionId as any
)
const result = store.nodeLocatorIdToExecutionId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
)
expect(workflowStore.nodeLocatorIdToNodeExecutionId).toHaveBeenCalledWith(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
)
expect(result).toBe(mockExecutionId)
})
it('should return null when conversion fails', () => {
vi.spyOn(workflowStore, 'nodeLocatorIdToNodeExecutionId').mockReturnValue(
null
)
const result = store.nodeLocatorIdToExecutionId('invalid:format')
expect(result).toBeNull()
})
})
})

View File

@@ -1,3 +1,4 @@
import type { Subgraph } from '@comfyorg/litegraph'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -11,6 +12,7 @@ import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/stores/workflowStore'
import { isSubgraph } from '@/utils/typeGuardUtil'
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
@@ -26,10 +28,15 @@ vi.mock('@/scripts/api', () => ({
// Mock comfyApp globally for the store setup
vi.mock('@/scripts/app', () => ({
app: {
canvas: null // Start with canvas potentially undefined or null
canvas: {} // Start with empty canvas object
}
}))
// Mock isSubgraph
vi.mock('@/utils/typeGuardUtil', () => ({
isSubgraph: vi.fn(() => false)
}))
describe('useWorkflowStore', () => {
let store: ReturnType<typeof useWorkflowStore>
let bookmarkStore: ReturnType<typeof useWorkflowBookmarkStore>
@@ -518,8 +525,13 @@ describe('useWorkflowStore', () => {
{ name: 'Level 1 Subgraph' },
{ name: 'Level 2 Subgraph' }
]
}
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
} as any
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph
// Mock isSubgraph to return true for our mockSubgraph
vi.mocked(isSubgraph).mockImplementation(
(obj): obj is Subgraph => obj === mockSubgraph
)
// Act: Trigger the update
store.updateActiveGraph()
@@ -536,8 +548,13 @@ describe('useWorkflowStore', () => {
name: 'Initial Subgraph',
pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }],
isRootGraph: false
}
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph as any
} as any
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph
// Mock isSubgraph to return true for our initialSubgraph
vi.mocked(isSubgraph).mockImplementation(
(obj): obj is Subgraph => obj === initialSubgraph
)
// Trigger initial update based on the *first* workflow opened in beforeEach
store.updateActiveGraph()
@@ -561,6 +578,11 @@ describe('useWorkflowStore', () => {
// This ensures the watcher *does* cause a state change we can assert
vi.mocked(comfyApp.canvas).subgraph = undefined
// Mock isSubgraph to return false for undefined
vi.mocked(isSubgraph).mockImplementation(
(_obj): _obj is Subgraph => false
)
await store.openWorkflow(workflow2) // This changes activeWorkflow and triggers the watch
await nextTick() // Allow watcher and potential async operations in updateActiveGraph to complete
@@ -569,4 +591,131 @@ describe('useWorkflowStore', () => {
expect(store.activeSubgraph).toBeUndefined()
})
})
describe('NodeLocatorId conversions', () => {
beforeEach(() => {
// Setup mock graph structure with subgraphs
const mockSubgraph = {
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
_nodes: []
}
const mockNode = {
id: 123,
isSubgraphNode: () => true,
subgraph: mockSubgraph
}
const mockRootGraph = {
_nodes: [mockNode],
subgraphs: new Map([[mockSubgraph.id, mockSubgraph]]),
getNodeById: (id: string | number) => {
if (String(id) === '123') return mockNode
return null
}
}
vi.mocked(comfyApp).graph = mockRootGraph as any
vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any
store.activeSubgraph = mockSubgraph as any
})
describe('nodeIdToNodeLocatorId', () => {
it('should convert node ID to NodeLocatorId for subgraph nodes', () => {
const result = store.nodeIdToNodeLocatorId(456)
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
})
it('should return simple node ID for root graph nodes', () => {
store.activeSubgraph = undefined
const result = store.nodeIdToNodeLocatorId(123)
expect(result).toBe('123')
})
it('should use provided subgraph instead of active one', () => {
const customSubgraph = {
id: 'custom-uuid-1234-5678-90ab-cdef12345678'
} as any
const result = store.nodeIdToNodeLocatorId(789, customSubgraph)
expect(result).toBe('custom-uuid-1234-5678-90ab-cdef12345678:789')
})
})
describe('nodeExecutionIdToNodeLocatorId', () => {
it('should convert execution ID to NodeLocatorId', () => {
const result = store.nodeExecutionIdToNodeLocatorId('123:456')
expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456')
})
it('should return simple node ID for root level nodes', () => {
const result = store.nodeExecutionIdToNodeLocatorId('123')
expect(result).toBe('123')
})
it('should return null for invalid execution IDs', () => {
const result = store.nodeExecutionIdToNodeLocatorId('999:456')
expect(result).toBeNull()
})
})
describe('nodeLocatorIdToNodeId', () => {
it('should extract node ID from NodeLocatorId', () => {
const result = store.nodeLocatorIdToNodeId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
)
expect(result).toBe(456)
})
it('should handle string node IDs', () => {
const result = store.nodeLocatorIdToNodeId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:node_1'
)
expect(result).toBe('node_1')
})
it('should handle simple node IDs (root graph)', () => {
const result = store.nodeLocatorIdToNodeId('123')
expect(result).toBe(123)
const stringResult = store.nodeLocatorIdToNodeId('node_1')
expect(stringResult).toBe('node_1')
})
it('should return null for invalid NodeLocatorId', () => {
const result = store.nodeLocatorIdToNodeId('invalid:format')
expect(result).toBeNull()
})
})
describe('nodeLocatorIdToNodeExecutionId', () => {
it('should convert NodeLocatorId to execution ID', () => {
// Need to mock isSubgraph to identify our mockSubgraph
vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => {
return obj === store.activeSubgraph
})
const result = store.nodeLocatorIdToNodeExecutionId(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456'
)
expect(result).toBe('123:456')
})
it('should handle simple node IDs (root graph)', () => {
const result = store.nodeLocatorIdToNodeExecutionId('123')
expect(result).toBe('123')
})
it('should return null for unknown subgraph UUID', () => {
const result = store.nodeLocatorIdToNodeExecutionId(
'unknown-uuid-1234-5678-90ab-cdef12345678:456'
)
expect(result).toBeNull()
})
it('should return null for invalid NodeLocatorId', () => {
const result = store.nodeLocatorIdToNodeExecutionId('invalid:format')
expect(result).toBeNull()
})
})
})
})

View File

@@ -0,0 +1,207 @@
import { describe, expect, it } from 'vitest'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import {
type NodeLocatorId,
createNodeExecutionId,
createNodeLocatorId,
isNodeExecutionId,
isNodeLocatorId,
parseNodeExecutionId,
parseNodeLocatorId
} from '@/types/nodeIdentification'
describe('nodeIdentification', () => {
describe('NodeLocatorId', () => {
const validUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const validNodeId = '123'
const validNodeLocatorId = `${validUuid}:${validNodeId}` as NodeLocatorId
describe('isNodeLocatorId', () => {
it('should return true for valid NodeLocatorId', () => {
expect(isNodeLocatorId(validNodeLocatorId)).toBe(true)
expect(isNodeLocatorId(`${validUuid}:456`)).toBe(true)
expect(isNodeLocatorId(`${validUuid}:node_1`)).toBe(true)
// Simple node IDs (root graph)
expect(isNodeLocatorId('123')).toBe(true)
expect(isNodeLocatorId('node_1')).toBe(true)
expect(isNodeLocatorId('5')).toBe(true)
})
it('should return false for invalid formats', () => {
expect(isNodeLocatorId('123:456')).toBe(false) // No UUID in first part
expect(isNodeLocatorId('not-a-uuid:123')).toBe(false)
expect(isNodeLocatorId('')).toBe(false) // Empty string
expect(isNodeLocatorId(':123')).toBe(false) // Empty UUID
expect(isNodeLocatorId(`${validUuid}:`)).toBe(false) // Empty node ID
expect(isNodeLocatorId(`${validUuid}:123:456`)).toBe(false) // Too many parts
expect(isNodeLocatorId(123)).toBe(false) // Not a string
expect(isNodeLocatorId(null)).toBe(false)
expect(isNodeLocatorId(undefined)).toBe(false)
})
it('should validate UUID format correctly', () => {
// Valid UUID formats
expect(
isNodeLocatorId('00000000-0000-0000-0000-000000000000:123')
).toBe(true)
expect(
isNodeLocatorId('A1B2C3D4-E5F6-7890-ABCD-EF1234567890:123')
).toBe(true)
// Invalid UUID formats
expect(isNodeLocatorId('00000000-0000-0000-0000-00000000000:123')).toBe(
false
) // Too short
expect(
isNodeLocatorId('00000000-0000-0000-0000-0000000000000:123')
).toBe(false) // Too long
expect(
isNodeLocatorId('00000000_0000_0000_0000_000000000000:123')
).toBe(false) // Wrong separator
expect(
isNodeLocatorId('g0000000-0000-0000-0000-000000000000:123')
).toBe(false) // Invalid hex
})
})
describe('parseNodeLocatorId', () => {
it('should parse valid NodeLocatorId', () => {
const result = parseNodeLocatorId(validNodeLocatorId)
expect(result).toEqual({
subgraphUuid: validUuid,
localNodeId: 123
})
})
it('should handle string node IDs', () => {
const stringNodeId = `${validUuid}:node_1`
const result = parseNodeLocatorId(stringNodeId)
expect(result).toEqual({
subgraphUuid: validUuid,
localNodeId: 'node_1'
})
})
it('should handle simple node IDs (root graph)', () => {
const result = parseNodeLocatorId('123')
expect(result).toEqual({
subgraphUuid: null,
localNodeId: 123
})
const stringResult = parseNodeLocatorId('node_1')
expect(stringResult).toEqual({
subgraphUuid: null,
localNodeId: 'node_1'
})
})
it('should return null for invalid formats', () => {
expect(parseNodeLocatorId('123:456')).toBeNull() // No UUID in first part
expect(parseNodeLocatorId('')).toBeNull()
})
})
describe('createNodeLocatorId', () => {
it('should create NodeLocatorId from components', () => {
const result = createNodeLocatorId(validUuid, 123)
expect(result).toBe(validNodeLocatorId)
expect(isNodeLocatorId(result)).toBe(true)
})
it('should handle string node IDs', () => {
const result = createNodeLocatorId(validUuid, 'node_1')
expect(result).toBe(`${validUuid}:node_1`)
expect(isNodeLocatorId(result)).toBe(true)
})
})
})
describe('NodeExecutionId', () => {
describe('isNodeExecutionId', () => {
it('should return true for execution IDs', () => {
expect(isNodeExecutionId('123:456')).toBe(true)
expect(isNodeExecutionId('123:456:789')).toBe(true)
expect(isNodeExecutionId('node_1:node_2')).toBe(true)
})
it('should return false for non-execution IDs', () => {
expect(isNodeExecutionId('123')).toBe(false)
expect(isNodeExecutionId('node_1')).toBe(false)
expect(isNodeExecutionId('')).toBe(false)
expect(isNodeExecutionId(123)).toBe(false)
expect(isNodeExecutionId(null)).toBe(false)
expect(isNodeExecutionId(undefined)).toBe(false)
})
})
describe('parseNodeExecutionId', () => {
it('should parse execution IDs correctly', () => {
expect(parseNodeExecutionId('123:456')).toEqual([123, 456])
expect(parseNodeExecutionId('123:456:789')).toEqual([123, 456, 789])
expect(parseNodeExecutionId('node_1:node_2')).toEqual([
'node_1',
'node_2'
])
expect(parseNodeExecutionId('123:node_2:456')).toEqual([
123,
'node_2',
456
])
})
it('should return null for non-execution IDs', () => {
expect(parseNodeExecutionId('123')).toBeNull()
expect(parseNodeExecutionId('')).toBeNull()
})
})
describe('createNodeExecutionId', () => {
it('should create execution IDs from node arrays', () => {
expect(createNodeExecutionId([123, 456])).toBe('123:456')
expect(createNodeExecutionId([123, 456, 789])).toBe('123:456:789')
expect(createNodeExecutionId(['node_1', 'node_2'])).toBe(
'node_1:node_2'
)
expect(createNodeExecutionId([123, 'node_2', 456])).toBe(
'123:node_2:456'
)
})
it('should handle single node ID', () => {
const result = createNodeExecutionId([123])
expect(result).toBe('123')
// Single node IDs are not execution IDs
expect(isNodeExecutionId(result)).toBe(false)
})
it('should handle empty array', () => {
expect(createNodeExecutionId([])).toBe('')
})
})
})
describe('Integration tests', () => {
it('should round-trip NodeLocatorId correctly', () => {
const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const nodeId: NodeId = 123
const locatorId = createNodeLocatorId(uuid, nodeId)
const parsed = parseNodeLocatorId(locatorId)
expect(parsed).toBeTruthy()
expect(parsed!.subgraphUuid).toBe(uuid)
expect(parsed!.localNodeId).toBe(nodeId)
})
it('should round-trip NodeExecutionId correctly', () => {
const nodeIds: NodeId[] = [123, 'node_2', 456]
const executionId = createNodeExecutionId(nodeIds)
const parsed = parseNodeExecutionId(executionId)
expect(parsed).toEqual(nodeIds)
})
})
})