From 7fe4c07a9c8964a7dee25ecc6bfdc06f2d850fd3 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Mon, 28 Jul 2025 20:53:05 -0700 Subject: [PATCH] [fix] Preserve subgraph structure when clearing workflow (#4567) Co-authored-by: github-actions --- src/composables/useCoreCommands.ts | 8 +- src/locales/es/main.json | 13 ++ src/locales/fr/main.json | 13 ++ src/locales/ja/main.json | 13 ++ src/locales/ko/main.json | 13 ++ src/locales/ru/main.json | 13 ++ src/locales/zh-TW/main.json | 13 ++ src/locales/zh/main.json | 13 ++ src/utils/graphTraversalUtil.ts | 13 ++ src/utils/typeGuardUtil.ts | 13 ++ .../tests/composables/useCoreCommands.test.ts | 187 ++++++++++++++++++ .../store/subgraphNavigationStore.test.ts | 113 +++++++++++ .../tests/utils/graphTraversalUtil.test.ts | 50 +++++ tests-ui/tests/utils/typeGuardUtil.test.ts | 45 +++++ 14 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 tests-ui/tests/composables/useCoreCommands.test.ts create mode 100644 tests-ui/tests/store/subgraphNavigationStore.test.ts create mode 100644 tests-ui/tests/utils/typeGuardUtil.test.ts diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index a1523ab70..055d85b0a 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -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() } diff --git a/src/locales/es/main.json b/src/locales/es/main.json index 9038e6a23..1be05f03a 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -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" diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 0f595a540..cddc85f86 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -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" diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 837d9afbc..2e3256eba 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -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へようこそ" diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index 863f0ce9c..578c149cf 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -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에 오신 것을 환영합니다" diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index e16cc9f13..d8197617a 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -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" diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index 033149e93..2b5debb77 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -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" diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 49f221e91..c1e7bf975 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -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" diff --git a/src/utils/graphTraversalUtil.ts b/src/utils/graphTraversalUtil.ts index ecb116bf6..5fcf0b369 100644 --- a/src/utils/graphTraversalUtil.ts +++ b/src/utils/graphTraversalUtil.ts @@ -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( 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)) +} diff --git a/src/utils/typeGuardUtil.ts b/src/utils/typeGuardUtil.ts index c35a9716b..a3bcb5f3d 100644 --- a/src/utils/typeGuardUtil.ts +++ b/src/utils/typeGuardUtil.ts @@ -27,3 +27,16 @@ export const isSubgraph = ( */ export const isNonNullish = (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' +} diff --git a/tests-ui/tests/composables/useCoreCommands.test.ts b/tests-ui/tests/composables/useCoreCommands.test.ts new file mode 100644 index 000000000..a9e801858 --- /dev/null +++ b/tests-ui/tests/composables/useCoreCommands.test.ts @@ -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() + }) + }) +}) diff --git a/tests-ui/tests/store/subgraphNavigationStore.test.ts b/tests-ui/tests/store/subgraphNavigationStore.test.ts new file mode 100644 index 000000000..4ee63353b --- /dev/null +++ b/tests-ui/tests/store/subgraphNavigationStore.test.ts @@ -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) + }) +}) diff --git a/tests-ui/tests/utils/graphTraversalUtil.test.ts b/tests-ui/tests/utils/graphTraversalUtil.test.ts index 2241a248a..b3b3dd23a 100644 --- a/tests-ui/tests/utils/graphTraversalUtil.test.ts +++ b/tests-ui/tests/utils/graphTraversalUtil.test.ts @@ -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) + }) + }) }) }) diff --git a/tests-ui/tests/utils/typeGuardUtil.test.ts b/tests-ui/tests/utils/typeGuardUtil.test.ts new file mode 100644 index 000000000..9c0689271 --- /dev/null +++ b/tests-ui/tests/utils/typeGuardUtil.test.ts @@ -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) + }) + }) +})