[fix] Preserve subgraph structure when clearing workflow (#4567)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2025-07-28 20:53:05 -07:00
committed by GitHub
parent 577cd23c3e
commit 7fe4c07a9c
14 changed files with 519 additions and 1 deletions

View File

@@ -29,6 +29,7 @@ import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { getAllNonIoNodesInSubgraph } from '@/utils/graphTraversalUtil'
const moveSelectedNodesVersionAdded = '1.22.2'
@@ -172,7 +173,12 @@ export function useCoreCommands(): ComfyCommand[] {
) {
app.clean()
if (app.canvas.subgraph) {
app.canvas.subgraph.clear()
// `clear` is not implemented on subgraphs and the parent class's
// (`LGraph`) `clear` breaks the subgraph structure. For subgraphs,
// just clear the nodes but preserve input/output nodes and structure
const subgraph = app.canvas.subgraph
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
nonIoNodes.forEach((node) => subgraph.remove(node))
} else {
app.graph.clear()
}

View File

@@ -296,6 +296,7 @@
"devices": "Dispositivos",
"disableAll": "Deshabilitar todo",
"disabling": "Deshabilitando",
"dismiss": "Descartar",
"download": "Descargar",
"edit": "Editar",
"empty": "Vacío",
@@ -310,6 +311,8 @@
"filter": "Filtrar",
"findIssues": "Encontrar problemas",
"firstTimeUIMessage": "Esta es la primera vez que usas la nueva interfaz. Elige \"Menú > Usar nuevo menú > Desactivado\" para restaurar la antigua interfaz.",
"frontendNewer": "La versión del frontend {frontendVersion} puede no ser compatible con la versión del backend {backendVersion}.",
"frontendOutdated": "La versión del frontend {frontendVersion} está desactualizada. El backend requiere la versión {requiredVersion} o superior.",
"goToNode": "Ir al nodo",
"help": "Ayuda",
"icon": "Icono",
@@ -389,11 +392,14 @@
"unknownError": "Error desconocido",
"update": "Actualizar",
"updateAvailable": "Actualización Disponible",
"updateFrontend": "Actualizar frontend",
"updated": "Actualizado",
"updating": "Actualizando",
"upload": "Subir",
"usageHint": "Sugerencia de uso",
"user": "Usuario",
"versionMismatchWarning": "Advertencia de compatibilidad de versión",
"versionMismatchWarningMessage": "{warning}: {detail} Visita https://docs.comfy.org/installation/update_comfyui#common-update-issues para obtener instrucciones de actualización.",
"videoFailedToLoad": "Falló la carga del video",
"workflow": "Flujo de trabajo"
},
@@ -1602,6 +1608,13 @@
"prefix": "Debe comenzar con {prefix}",
"required": "Requerido"
},
"versionMismatchWarning": {
"dismiss": "Descartar",
"frontendNewer": "La versión del frontend {frontendVersion} puede no ser compatible con la versión del backend {backendVersion}.",
"frontendOutdated": "La versión del frontend {frontendVersion} está desactualizada. El backend requiere la versión {requiredVersion} o superior.",
"title": "Advertencia de compatibilidad de versión",
"updateFrontend": "Actualizar frontend"
},
"welcome": {
"getStarted": "Empezar",
"title": "Bienvenido a ComfyUI"

View File

@@ -296,6 +296,7 @@
"devices": "Appareils",
"disableAll": "Désactiver tout",
"disabling": "Désactivation",
"dismiss": "Fermer",
"download": "Télécharger",
"edit": "Modifier",
"empty": "Vide",
@@ -310,6 +311,8 @@
"filter": "Filtrer",
"findIssues": "Trouver des problèmes",
"firstTimeUIMessage": "C'est la première fois que vous utilisez la nouvelle interface utilisateur. Choisissez \"Menu > Utiliser le nouveau menu > Désactivé\" pour restaurer l'ancienne interface utilisateur.",
"frontendNewer": "La version du frontend {frontendVersion} peut ne pas être compatible avec la version du backend {backendVersion}.",
"frontendOutdated": "La version du frontend {frontendVersion} est obsolète. Le backend requiert la version {requiredVersion} ou supérieure.",
"goToNode": "Aller au nœud",
"help": "Aide",
"icon": "Icône",
@@ -389,11 +392,14 @@
"unknownError": "Erreur inconnue",
"update": "Mettre à jour",
"updateAvailable": "Mise à jour disponible",
"updateFrontend": "Mettre à jour le frontend",
"updated": "Mis à jour",
"updating": "Mise à jour",
"upload": "Téléverser",
"usageHint": "Conseil d'utilisation",
"user": "Utilisateur",
"versionMismatchWarning": "Avertissement de compatibilité de version",
"versionMismatchWarningMessage": "{warning} : {detail} Consultez https://docs.comfy.org/installation/update_comfyui#common-update-issues pour les instructions de mise à jour.",
"videoFailedToLoad": "Échec du chargement de la vidéo",
"workflow": "Flux de travail"
},
@@ -1602,6 +1608,13 @@
"prefix": "Doit commencer par {prefix}",
"required": "Requis"
},
"versionMismatchWarning": {
"dismiss": "Ignorer",
"frontendNewer": "La version du frontend {frontendVersion} peut ne pas être compatible avec la version du backend {backendVersion}.",
"frontendOutdated": "La version du frontend {frontendVersion} est obsolète. Le backend nécessite la version {requiredVersion} ou supérieure.",
"title": "Avertissement de compatibilité de version",
"updateFrontend": "Mettre à jour le frontend"
},
"welcome": {
"getStarted": "Commencer",
"title": "Bienvenue sur ComfyUI"

View File

@@ -296,6 +296,7 @@
"devices": "デバイス",
"disableAll": "すべて無効にする",
"disabling": "無効化",
"dismiss": "閉じる",
"download": "ダウンロード",
"edit": "編集",
"empty": "空",
@@ -310,6 +311,8 @@
"filter": "フィルタ",
"findIssues": "問題を見つける",
"firstTimeUIMessage": "新しいUIを初めて使用しています。「メニュー > 新しいメニューを使用 > 無効」を選択することで古いUIに戻すことが可能です。",
"frontendNewer": "フロントエンドのバージョン {frontendVersion} はバックエンドのバージョン {backendVersion} と互換性がない可能性があります。",
"frontendOutdated": "フロントエンドのバージョン {frontendVersion} は古くなっています。バックエンドは {requiredVersion} 以上が必要です。",
"goToNode": "ノードに移動",
"help": "ヘルプ",
"icon": "アイコン",
@@ -389,11 +392,14 @@
"unknownError": "不明なエラー",
"update": "更新",
"updateAvailable": "更新が利用可能",
"updateFrontend": "フロントエンドを更新",
"updated": "更新済み",
"updating": "更新中",
"upload": "アップロード",
"usageHint": "使用ヒント",
"user": "ユーザー",
"versionMismatchWarning": "バージョン互換性の警告",
"versionMismatchWarningMessage": "{warning}: {detail} 更新手順については https://docs.comfy.org/installation/update_comfyui#common-update-issues をご覧ください。",
"videoFailedToLoad": "ビデオの読み込みに失敗しました",
"workflow": "ワークフロー"
},
@@ -1602,6 +1608,13 @@
"prefix": "{prefix}で始める必要があります",
"required": "必須"
},
"versionMismatchWarning": {
"dismiss": "閉じる",
"frontendNewer": "フロントエンドのバージョン {frontendVersion} は、バックエンドのバージョン {backendVersion} と互換性がない可能性があります。",
"frontendOutdated": "フロントエンドのバージョン {frontendVersion} は古くなっています。バックエンドはバージョン {requiredVersion} 以上が必要です。",
"title": "バージョン互換性の警告",
"updateFrontend": "フロントエンドを更新"
},
"welcome": {
"getStarted": "はじめる",
"title": "ComfyUIへようこそ"

View File

@@ -296,6 +296,7 @@
"devices": "장치",
"disableAll": "모두 비활성화",
"disabling": "비활성화 중",
"dismiss": "닫기",
"download": "다운로드",
"edit": "편집",
"empty": "비어 있음",
@@ -310,6 +311,8 @@
"filter": "필터",
"findIssues": "문제 찾기",
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
"frontendNewer": "프론트엔드 버전 {frontendVersion}이(가) 백엔드 버전 {backendVersion}과(와) 호환되지 않을 수 있습니다.",
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래되었습니다. 백엔드는 {requiredVersion} 이상이 필요합니다.",
"goToNode": "노드로 이동",
"help": "도움말",
"icon": "아이콘",
@@ -389,11 +392,14 @@
"unknownError": "알 수 없는 오류",
"update": "업데이트",
"updateAvailable": "업데이트 가능",
"updateFrontend": "프론트엔드 업데이트",
"updated": "업데이트 됨",
"updating": "업데이트 중",
"upload": "업로드",
"usageHint": "사용 힌트",
"user": "사용자",
"versionMismatchWarning": "버전 호환성 경고",
"versionMismatchWarningMessage": "{warning}: {detail} 업데이트 지침은 https://docs.comfy.org/installation/update_comfyui#common-update-issues 를 방문하세요.",
"videoFailedToLoad": "비디오를 로드하지 못했습니다.",
"workflow": "워크플로"
},
@@ -1602,6 +1608,13 @@
"prefix": "{prefix}(으)로 시작해야 합니다",
"required": "필수"
},
"versionMismatchWarning": {
"dismiss": "닫기",
"frontendNewer": "프론트엔드 버전 {frontendVersion}이(가) 백엔드 버전 {backendVersion}과(와) 호환되지 않을 수 있습니다.",
"frontendOutdated": "프론트엔드 버전 {frontendVersion}이(가) 오래되었습니다. 백엔드는 {requiredVersion} 이상 버전을 필요로 합니다.",
"title": "버전 호환성 경고",
"updateFrontend": "프론트엔드 업데이트"
},
"welcome": {
"getStarted": "시작하기",
"title": "ComfyUI에 오신 것을 환영합니다"

View File

@@ -296,6 +296,7 @@
"devices": "Устройства",
"disableAll": "Отключить все",
"disabling": "Отключение",
"dismiss": "Закрыть",
"download": "Скачать",
"edit": "Редактировать",
"empty": "Пусто",
@@ -310,6 +311,8 @@
"filter": "Фильтр",
"findIssues": "Найти проблемы",
"firstTimeUIMessage": "Вы впервые используете новый интерфейс. Выберите \"Меню > Использовать новое меню > Отключено\", чтобы восстановить старый интерфейс.",
"frontendNewer": "Версия интерфейса {frontendVersion} может быть несовместима с версией сервера {backendVersion}.",
"frontendOutdated": "Версия интерфейса {frontendVersion} устарела. Требуется версия не ниже {requiredVersion} для работы с сервером.",
"goToNode": "Перейти к ноде",
"help": "Помощь",
"icon": "Иконка",
@@ -389,11 +392,14 @@
"unknownError": "Неизвестная ошибка",
"update": "Обновить",
"updateAvailable": "Доступно обновление",
"updateFrontend": "Обновить интерфейс",
"updated": "Обновлено",
"updating": "Обновление",
"upload": "Загрузить",
"usageHint": "Подсказка по использованию",
"user": "Пользователь",
"versionMismatchWarning": "Предупреждение о несовместимости версий",
"versionMismatchWarningMessage": "{warning}: {detail} Посетите https://docs.comfy.org/installation/update_comfyui#common-update-issues для инструкций по обновлению.",
"videoFailedToLoad": "Не удалось загрузить видео",
"workflow": "Рабочий процесс"
},
@@ -1602,6 +1608,13 @@
"prefix": "Должно начинаться с {prefix}",
"required": "Обязательно"
},
"versionMismatchWarning": {
"dismiss": "Закрыть",
"frontendNewer": "Версия интерфейса {frontendVersion} может быть несовместима с версией сервера {backendVersion}.",
"frontendOutdated": "Версия интерфейса {frontendVersion} устарела. Для работы с сервером требуется версия {requiredVersion} или новее.",
"title": "Предупреждение о несовместимости версий",
"updateFrontend": "Обновить интерфейс"
},
"welcome": {
"getStarted": "Начать",
"title": "Добро пожаловать в ComfyUI"

View File

@@ -296,6 +296,7 @@
"devices": "裝置",
"disableAll": "全部停用",
"disabling": "停用中",
"dismiss": "關閉",
"download": "下載",
"edit": "編輯",
"empty": "空",
@@ -310,6 +311,8 @@
"filter": "篩選",
"findIssues": "尋找問題",
"firstTimeUIMessage": "這是您第一次使用新介面。若要返回舊介面,請前往「選單」>「使用新介面」>「關閉」。",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 或更高版本。",
"goToNode": "前往節點",
"help": "說明",
"icon": "圖示",
@@ -389,11 +392,14 @@
"unknownError": "未知錯誤",
"update": "更新",
"updateAvailable": "有可用更新",
"updateFrontend": "更新前端",
"updated": "已更新",
"updating": "更新中",
"upload": "上傳",
"usageHint": "使用提示",
"user": "使用者",
"versionMismatchWarning": "版本相容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。",
"videoFailedToLoad": "無法載入影片",
"workflow": "工作流程"
},
@@ -1602,6 +1608,13 @@
"prefix": "必須以 {prefix} 開頭",
"required": "必填"
},
"versionMismatchWarning": {
"dismiss": "關閉",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要版本 {requiredVersion} 或更高版本。",
"title": "版本相容性警告",
"updateFrontend": "更新前端"
},
"welcome": {
"getStarted": "開始使用",
"title": "歡迎使用 ComfyUI"

View File

@@ -296,6 +296,7 @@
"devices": "设备",
"disableAll": "禁用全部",
"disabling": "禁用中",
"dismiss": "關閉",
"download": "下载",
"edit": "编辑",
"empty": "空",
@@ -310,6 +311,8 @@
"filter": "过滤",
"findIssues": "查找问题",
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 或更高版本。",
"goToNode": "转到节点",
"help": "帮助",
"icon": "图标",
@@ -389,11 +392,14 @@
"unknownError": "未知错误",
"update": "更新",
"updateAvailable": "有更新可用",
"updateFrontend": "更新前端",
"updated": "已更新",
"updating": "更新中",
"upload": "上传",
"usageHint": "使用提示",
"user": "用户",
"versionMismatchWarning": "版本相容性警告",
"versionMismatchWarningMessage": "{warning}{detail} 請參閱 https://docs.comfy.org/installation/update_comfyui#common-update-issues 以取得更新說明。",
"videoFailedToLoad": "视频加载失败",
"workflow": "工作流"
},
@@ -1602,6 +1608,13 @@
"prefix": "必须以 {prefix} 开头",
"required": "必填"
},
"versionMismatchWarning": {
"dismiss": "關閉",
"frontendNewer": "前端版本 {frontendVersion} 可能與後端版本 {backendVersion} 不相容。",
"frontendOutdated": "前端版本 {frontendVersion} 已過時。後端需要 {requiredVersion} 版或更高版本。",
"title": "版本相容性警告",
"updateFrontend": "更新前端"
},
"welcome": {
"getStarted": "开始使用",
"title": "欢迎使用 ComfyUI"

View File

@@ -3,6 +3,8 @@ import type { LGraph, LGraphNode, Subgraph } from '@comfyorg/litegraph'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { parseNodeLocatorId } from '@/types/nodeIdentification'
import { isSubgraphIoNode } from './typeGuardUtil'
/**
* Parses an execution ID into its component parts.
*
@@ -338,3 +340,14 @@ export function mapSubgraphNodes<T>(
return undefined
})
}
/**
* Gets all non-IO nodes from a subgraph (excludes SubgraphInputNode and SubgraphOutputNode).
* These are the user-created nodes that can be safely removed when clearing a subgraph.
*
* @param subgraph - The subgraph to get non-IO nodes from
* @returns Array of non-IO nodes (user-created nodes)
*/
export function getAllNonIoNodesInSubgraph(subgraph: Subgraph): LGraphNode[] {
return subgraph.nodes.filter((node) => !isSubgraphIoNode(node))
}

View File

@@ -27,3 +27,16 @@ export const isSubgraph = (
*/
export const isNonNullish = <T>(item: T | undefined | null): item is T =>
item != null
/**
* Type guard to check if a node is a subgraph input/output node.
* These nodes are essential to subgraph structure and should not be removed.
*/
export const isSubgraphIoNode = (
node: LGraphNode
): node is LGraphNode & {
constructor: { comfyClass: 'SubgraphInputNode' | 'SubgraphOutputNode' }
} => {
const nodeClass = node.constructor?.comfyClass
return nodeClass === 'SubgraphInputNode' || nodeClass === 'SubgraphOutputNode'
}

View File

@@ -0,0 +1,187 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCoreCommands } from '@/composables/useCoreCommands'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
vi.mock('@/scripts/app', () => ({
app: {
clean: vi.fn(),
canvas: {
subgraph: null
},
graph: {
clear: vi.fn()
}
}
}))
vi.mock('@/scripts/api', () => ({
api: {
dispatchCustomEvent: vi.fn(),
apiURL: vi.fn(() => 'http://localhost:8188')
}
}))
vi.mock('@/stores/settingStore')
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useFirebaseAuth', () => ({
useFirebaseAuth: vi.fn(() => null)
}))
vi.mock('firebase/auth', () => ({
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn()
}))
vi.mock('@/services/workflowService', () => ({
useWorkflowService: vi.fn(() => ({}))
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({}))
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: vi.fn(() => ({}))
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => ({}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({}))
}))
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({}))
}))
describe('useCoreCommands', () => {
const mockSubgraph = {
nodes: [
// Mock input node
{
constructor: { comfyClass: 'SubgraphInputNode' },
id: 'input1'
},
// Mock output node
{
constructor: { comfyClass: 'SubgraphOutputNode' },
id: 'output1'
},
// Mock user node
{
constructor: { comfyClass: 'SomeUserNode' },
id: 'user1'
},
// Another mock user node
{
constructor: { comfyClass: 'AnotherUserNode' },
id: 'user2'
}
],
remove: vi.fn()
}
beforeEach(() => {
vi.clearAllMocks()
// Set up Pinia
setActivePinia(createPinia())
// Reset app state
app.canvas.subgraph = undefined
// Mock settings store
vi.mocked(useSettingStore).mockReturnValue({
get: vi.fn().mockReturnValue(false) // Skip confirmation dialog
} as any)
// Mock global confirm
global.confirm = vi.fn().mockReturnValue(true)
})
describe('ClearWorkflow command', () => {
it('should clear main graph when not in subgraph', async () => {
const commands = useCoreCommands()
const clearCommand = commands.find(
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
)!
// Execute the command
await clearCommand.function()
expect(app.clean).toHaveBeenCalled()
expect(app.graph.clear).toHaveBeenCalled()
expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared')
})
it('should preserve input/output nodes when clearing subgraph', async () => {
// Set up subgraph context
app.canvas.subgraph = mockSubgraph as any
const commands = useCoreCommands()
const clearCommand = commands.find(
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
)!
// Execute the command
await clearCommand.function()
expect(app.clean).toHaveBeenCalled()
expect(app.graph.clear).not.toHaveBeenCalled()
// Should only remove user nodes, not input/output nodes
expect(mockSubgraph.remove).toHaveBeenCalledTimes(2)
expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[2]) // user1
expect(mockSubgraph.remove).toHaveBeenCalledWith(mockSubgraph.nodes[3]) // user2
expect(mockSubgraph.remove).not.toHaveBeenCalledWith(
mockSubgraph.nodes[0]
) // input1
expect(mockSubgraph.remove).not.toHaveBeenCalledWith(
mockSubgraph.nodes[1]
) // output1
expect(api.dispatchCustomEvent).toHaveBeenCalledWith('graphCleared')
})
it('should respect confirmation setting', async () => {
// Mock confirmation required
vi.mocked(useSettingStore).mockReturnValue({
get: vi.fn().mockReturnValue(true) // Require confirmation
} as any)
global.confirm = vi.fn().mockReturnValue(false) // User cancels
const commands = useCoreCommands()
const clearCommand = commands.find(
(cmd) => cmd.id === 'Comfy.ClearWorkflow'
)!
// Execute the command
await clearCommand.function()
// Should not clear anything when user cancels
expect(app.clean).not.toHaveBeenCalled()
expect(app.graph.clear).not.toHaveBeenCalled()
expect(api.dispatchCustomEvent).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,113 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { ComfyWorkflow } from '@/stores/workflowStore'
vi.mock('@/scripts/app', () => ({
app: {
graph: {
subgraphs: new Map(),
getNodeById: vi.fn()
},
canvas: {
subgraph: null
}
}
}))
describe('useSubgraphNavigationStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('should not clear navigation stack when workflow internal state changes', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Mock a workflow
const mockWorkflow = {
path: 'test-workflow.json',
filename: 'test-workflow.json',
changeTracker: null
} as ComfyWorkflow
// Set the active workflow (cast to bypass TypeScript check in test)
workflowStore.activeWorkflow = mockWorkflow as any
// Simulate being in a subgraph by restoring state
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
expect(navigationStore.exportState()).toHaveLength(2)
// Simulate a change to the workflow's internal state
// (e.g., changeTracker.activeState being reassigned)
mockWorkflow.changeTracker = { activeState: {} } as any
// The navigation stack should NOT be cleared because the path hasn't changed
expect(navigationStore.exportState()).toHaveLength(2)
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
})
it('should clear navigation stack when switching to a different workflow', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// Mock first workflow
const workflow1 = {
path: 'workflow1.json',
filename: 'workflow1.json'
} as ComfyWorkflow
// Set the active workflow
workflowStore.activeWorkflow = workflow1 as any
// Simulate being in a subgraph
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
expect(navigationStore.exportState()).toHaveLength(2)
// Switch to a different workflow
const workflow2 = {
path: 'workflow2.json',
filename: 'workflow2.json'
} as ComfyWorkflow
workflowStore.activeWorkflow = workflow2 as any
// Wait for Vue's reactivity to process the change
await nextTick()
// The navigation stack SHOULD be cleared because we switched workflows
expect(navigationStore.exportState()).toHaveLength(0)
})
it('should handle null workflow gracefully', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
// First set an active workflow
const mockWorkflow = {
path: 'test-workflow.json',
filename: 'test-workflow.json'
} as ComfyWorkflow
workflowStore.activeWorkflow = mockWorkflow as any
await nextTick()
// Add some items to the navigation stack
navigationStore.restoreState(['subgraph-1'])
expect(navigationStore.exportState()).toHaveLength(1)
// Set workflow to null
workflowStore.activeWorkflow = null
// Wait for Vue's reactivity to process the change
await nextTick()
// Stack should be cleared when workflow becomes null
expect(navigationStore.exportState()).toHaveLength(0)
})
})

View File

@@ -7,6 +7,7 @@ import {
findSubgraphByUuid,
forEachNode,
forEachSubgraphNode,
getAllNonIoNodesInSubgraph,
getLocalNodeIdFromExecutionId,
getNodeByExecutionId,
getNodeByLocatorId,
@@ -757,5 +758,54 @@ describe('graphTraversalUtil', () => {
])
})
})
describe('getAllNonIoNodesInSubgraph', () => {
it('should filter out SubgraphInputNode and SubgraphOutputNode', () => {
const nodes = [
{ id: 'input', constructor: { comfyClass: 'SubgraphInputNode' } },
{ id: 'output', constructor: { comfyClass: 'SubgraphOutputNode' } },
{ id: 'user1', constructor: { comfyClass: 'CLIPTextEncode' } },
{ id: 'user2', constructor: { comfyClass: 'KSampler' } }
] as LGraphNode[]
const subgraph = createMockSubgraph('sub-uuid', nodes)
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
expect(nonIoNodes).toHaveLength(2)
expect(nonIoNodes.map((n) => n.id)).toEqual(['user1', 'user2'])
})
it('should handle subgraph with only IO nodes', () => {
const nodes = [
{ id: 'input', constructor: { comfyClass: 'SubgraphInputNode' } },
{ id: 'output', constructor: { comfyClass: 'SubgraphOutputNode' } }
] as LGraphNode[]
const subgraph = createMockSubgraph('sub-uuid', nodes)
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
expect(nonIoNodes).toHaveLength(0)
})
it('should handle subgraph with only user nodes', () => {
const nodes = [
{ id: 'user1', constructor: { comfyClass: 'CLIPTextEncode' } },
{ id: 'user2', constructor: { comfyClass: 'KSampler' } }
] as LGraphNode[]
const subgraph = createMockSubgraph('sub-uuid', nodes)
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
expect(nonIoNodes).toHaveLength(2)
expect(nonIoNodes).toEqual(nodes)
})
it('should handle empty subgraph', () => {
const subgraph = createMockSubgraph('sub-uuid', [])
const nonIoNodes = getAllNonIoNodesInSubgraph(subgraph)
expect(nonIoNodes).toHaveLength(0)
})
})
})
})

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { isSubgraphIoNode } from '@/utils/typeGuardUtil'
describe('typeGuardUtil', () => {
describe('isSubgraphIoNode', () => {
it('should identify SubgraphInputNode as IO node', () => {
const node = {
constructor: { comfyClass: 'SubgraphInputNode' }
} as any
expect(isSubgraphIoNode(node)).toBe(true)
})
it('should identify SubgraphOutputNode as IO node', () => {
const node = {
constructor: { comfyClass: 'SubgraphOutputNode' }
} as any
expect(isSubgraphIoNode(node)).toBe(true)
})
it('should not identify regular nodes as IO nodes', () => {
const node = {
constructor: { comfyClass: 'CLIPTextEncode' }
} as any
expect(isSubgraphIoNode(node)).toBe(false)
})
it('should handle nodes without constructor', () => {
const node = {} as any
expect(isSubgraphIoNode(node)).toBe(false)
})
it('should handle nodes without comfyClass', () => {
const node = {
constructor: {}
} as any
expect(isSubgraphIoNode(node)).toBe(false)
})
})
})