diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue
index e4db7b6be..1c1ecef20 100644
--- a/src/components/graph/GraphCanvas.vue
+++ b/src/components/graph/GraphCanvas.vue
@@ -325,6 +325,11 @@ onMounted(async () => {
await workflowPersistence.restorePreviousWorkflow()
workflowPersistence.restoreWorkflowTabsState()
+ // Initialize release store to fetch releases from comfy-api (fire-and-forget)
+ const { useReleaseStore } = await import('@/stores/releaseStore')
+ const releaseStore = useReleaseStore()
+ void releaseStore.initialize()
+
// Start watching for locale change after the initial value is loaded.
watch(
() => settingStore.get('Comfy.Locale'),
diff --git a/src/components/helpcenter/HelpCenterMenuContent.vue b/src/components/helpcenter/HelpCenterMenuContent.vue
new file mode 100644
index 000000000..b7f1f53b3
--- /dev/null
+++ b/src/components/helpcenter/HelpCenterMenuContent.vue
@@ -0,0 +1,586 @@
+
+
+
+
+
+
+
diff --git a/src/components/helpcenter/ReleaseNotificationToast.vue b/src/components/helpcenter/ReleaseNotificationToast.vue
new file mode 100644
index 000000000..4984b84d8
--- /dev/null
+++ b/src/components/helpcenter/ReleaseNotificationToast.vue
@@ -0,0 +1,308 @@
+
+
+
+
+
+
+
diff --git a/src/components/helpcenter/WhatsNewPopup.vue b/src/components/helpcenter/WhatsNewPopup.vue
new file mode 100644
index 000000000..e8c4c9d1b
--- /dev/null
+++ b/src/components/helpcenter/WhatsNewPopup.vue
@@ -0,0 +1,428 @@
+
+
+
+
+
+
+
diff --git a/src/components/sidebar/SideToolbar.vue b/src/components/sidebar/SideToolbar.vue
index 0f269ebb9..120747b54 100644
--- a/src/components/sidebar/SideToolbar.vue
+++ b/src/components/sidebar/SideToolbar.vue
@@ -14,6 +14,7 @@
+
@@ -36,6 +37,7 @@ import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
+import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarSettingsToggleIcon from './SidebarSettingsToggleIcon.vue'
diff --git a/src/components/sidebar/SidebarHelpCenterIcon.vue b/src/components/sidebar/SidebarHelpCenterIcon.vue
new file mode 100644
index 000000000..30537b75a
--- /dev/null
+++ b/src/components/sidebar/SidebarHelpCenterIcon.vue
@@ -0,0 +1,157 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/constants/coreSettings.ts b/src/constants/coreSettings.ts
index ec554f632..295ea01fd 100644
--- a/src/constants/coreSettings.ts
+++ b/src/constants/coreSettings.ts
@@ -847,5 +847,24 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: false,
versionAdded: '1.19.1'
+ },
+ // Release data stored in settings
+ {
+ id: 'Comfy.Release.Version',
+ name: 'Last seen release version',
+ type: 'hidden',
+ defaultValue: ''
+ },
+ {
+ id: 'Comfy.Release.Status',
+ name: 'Release status',
+ type: 'hidden',
+ defaultValue: 'skipped'
+ },
+ {
+ id: 'Comfy.Release.Timestamp',
+ name: 'Release seen timestamp',
+ type: 'hidden',
+ defaultValue: 0
}
]
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index 76ac5bf52..8844164fa 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -410,6 +410,7 @@
},
"sideToolbar": {
"themeToggle": "Toggle Theme",
+ "helpCenter": "Help Center",
"logout": "Logout",
"queue": "Queue",
"nodeLibrary": "Node Library",
@@ -468,6 +469,26 @@
}
}
},
+ "helpCenter": {
+ "docs": "Docs",
+ "github": "Github",
+ "helpFeedback": "Help & Feedback",
+ "more": "More...",
+ "whatsNew": "What's New?",
+ "clickToLearnMore": "Click to learn more →",
+ "loadingReleases": "Loading releases...",
+ "noRecentReleases": "No recent releases",
+ "updateAvailable": "Update",
+ "desktopUserGuide": "Desktop User Guide",
+ "openDevTools": "Open Dev Tools",
+ "reinstall": "Re-Install"
+ },
+ "releaseToast": {
+ "newVersionAvailable": "New Version Available!",
+ "whatsNew": "What's New?",
+ "skip": "Skip",
+ "update": "Update"
+ },
"menu": {
"hideMenu": "Hide Menu",
"showMenu": "Show Menu",
@@ -1444,5 +1465,8 @@
"moreHelp": "For more help, visit the",
"documentationPage": "documentation page",
"loadError": "Failed to load help: {error}"
+ },
+ "whatsNewPopup": {
+ "learnMore": "Learn more"
}
}
\ No newline at end of file
diff --git a/src/locales/es/main.json b/src/locales/es/main.json
index 7cdcaeca6..89cbc07e3 100644
--- a/src/locales/es/main.json
+++ b/src/locales/es/main.json
@@ -381,6 +381,20 @@
"create": "Crear nodo de grupo",
"enterName": "Introduzca el nombre"
},
+ "helpCenter": {
+ "clickToLearnMore": "Haz clic para saber más →",
+ "desktopUserGuide": "Guía de usuario de escritorio",
+ "docs": "Documentación",
+ "github": "Github",
+ "helpFeedback": "Ayuda y comentarios",
+ "loadingReleases": "Cargando versiones...",
+ "more": "Más...",
+ "noRecentReleases": "No hay versiones recientes",
+ "openDevTools": "Abrir herramientas de desarrollo",
+ "reinstall": "Reinstalar",
+ "updateAvailable": "Actualizar",
+ "whatsNew": "¿Qué hay de nuevo?"
+ },
"icon": {
"bookmark": "Marcador",
"box": "Caja",
@@ -872,6 +886,12 @@
},
"title": "Tu dispositivo no es compatible"
},
+ "releaseToast": {
+ "newVersionAvailable": "¡Nueva versión disponible!",
+ "skip": "Omitir",
+ "update": "Actualizar",
+ "whatsNew": "¿Qué hay de nuevo?"
+ },
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "No hay nodos de salida seleccionados",
@@ -1080,6 +1100,7 @@
"sideToolbar": {
"browseTemplates": "Explorar plantillas de ejemplo",
"downloads": "Descargas",
+ "helpCenter": "Centro de ayuda",
"logout": "Cerrar sesión",
"modelLibrary": "Biblioteca de modelos",
"newBlankWorkflow": "Crear un nuevo flujo de trabajo en blanco",
@@ -1440,6 +1461,9 @@
"getStarted": "Empezar",
"title": "Bienvenido a ComfyUI"
},
+ "whatsNewPopup": {
+ "learnMore": "Aprende más"
+ },
"workflowService": {
"enterFilename": "Introduzca el nombre del archivo",
"exportWorkflow": "Exportar flujo de trabajo",
diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json
index fc4d6e0ec..8f3c16496 100644
--- a/src/locales/fr/main.json
+++ b/src/locales/fr/main.json
@@ -381,6 +381,20 @@
"create": "Créer un nœud de groupe",
"enterName": "Entrer le nom"
},
+ "helpCenter": {
+ "clickToLearnMore": "Cliquez pour en savoir plus →",
+ "desktopUserGuide": "Guide utilisateur de bureau",
+ "docs": "Docs",
+ "github": "Github",
+ "helpFeedback": "Aide & Retour",
+ "loadingReleases": "Chargement des versions...",
+ "more": "Plus...",
+ "noRecentReleases": "Aucune version récente",
+ "openDevTools": "Ouvrir les outils de développement",
+ "reinstall": "Réinstaller",
+ "updateAvailable": "Mise à jour",
+ "whatsNew": "Quoi de neuf ?"
+ },
"icon": {
"bookmark": "Favori",
"box": "Boîte",
@@ -872,6 +886,12 @@
},
"title": "Votre appareil n'est pas pris en charge"
},
+ "releaseToast": {
+ "newVersionAvailable": "Nouvelle version disponible !",
+ "skip": "Ignorer",
+ "update": "Mettre à jour",
+ "whatsNew": "Quoi de neuf ?"
+ },
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "Aucun nœud de sortie sélectionné",
@@ -1080,6 +1100,7 @@
"sideToolbar": {
"browseTemplates": "Parcourir les modèles d'exemple",
"downloads": "Téléchargements",
+ "helpCenter": "Centre d'aide",
"logout": "Déconnexion",
"modelLibrary": "Bibliothèque de modèles",
"newBlankWorkflow": "Créer un nouveau flux de travail vierge",
@@ -1440,6 +1461,9 @@
"getStarted": "Commencer",
"title": "Bienvenue sur ComfyUI"
},
+ "whatsNewPopup": {
+ "learnMore": "En savoir plus"
+ },
"workflowService": {
"enterFilename": "Entrez le nom du fichier",
"exportWorkflow": "Exporter le flux de travail",
diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json
index 7f9b733e6..184ba829b 100644
--- a/src/locales/ja/main.json
+++ b/src/locales/ja/main.json
@@ -381,6 +381,20 @@
"create": "グループノードを作成",
"enterName": "名前を入力"
},
+ "helpCenter": {
+ "clickToLearnMore": "詳しくはこちらをクリック →",
+ "desktopUserGuide": "デスクトップユーザーガイド",
+ "docs": "ドキュメント",
+ "github": "Github",
+ "helpFeedback": "ヘルプとフィードバック",
+ "loadingReleases": "リリースを読み込み中...",
+ "more": "もっと見る...",
+ "noRecentReleases": "最近のリリースはありません",
+ "openDevTools": "開発者ツールを開く",
+ "reinstall": "再インストール",
+ "updateAvailable": "アップデート",
+ "whatsNew": "新着情報"
+ },
"icon": {
"bookmark": "ブックマーク",
"box": "ボックス",
@@ -872,6 +886,12 @@
},
"title": "お使いのデバイスはサポートされていません"
},
+ "releaseToast": {
+ "newVersionAvailable": "新しいバージョンが利用可能です!",
+ "skip": "スキップ",
+ "update": "アップデート",
+ "whatsNew": "新機能"
+ },
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "出力ノードが選択されていません",
@@ -1080,6 +1100,7 @@
"sideToolbar": {
"browseTemplates": "サンプルテンプレートを表示",
"downloads": "ダウンロード",
+ "helpCenter": "ヘルプセンター",
"logout": "ログアウト",
"modelLibrary": "モデルライブラリ",
"newBlankWorkflow": "新しい空のワークフローを作成",
@@ -1440,6 +1461,9 @@
"getStarted": "はじめる",
"title": "ComfyUIへようこそ"
},
+ "whatsNewPopup": {
+ "learnMore": "詳細はこちら"
+ },
"workflowService": {
"enterFilename": "ファイル名を入力",
"exportWorkflow": "ワークフローをエクスポート",
diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json
index 6fa26fec7..8247b6c2e 100644
--- a/src/locales/ko/main.json
+++ b/src/locales/ko/main.json
@@ -381,6 +381,20 @@
"create": "그룹 노드 만들기",
"enterName": "이름 입력"
},
+ "helpCenter": {
+ "clickToLearnMore": "자세히 알아보기 →",
+ "desktopUserGuide": "데스크톱 사용자 가이드",
+ "docs": "문서",
+ "github": "Github",
+ "helpFeedback": "도움말 및 피드백",
+ "loadingReleases": "릴리즈 불러오는 중...",
+ "more": "더보기...",
+ "noRecentReleases": "최근 릴리즈 없음",
+ "openDevTools": "개발자 도구 열기",
+ "reinstall": "재설치",
+ "updateAvailable": "업데이트",
+ "whatsNew": "새로운 소식?"
+ },
"icon": {
"bookmark": "북마크",
"box": "상자",
@@ -872,6 +886,12 @@
},
"title": "이 장치는 지원되지 않습니다."
},
+ "releaseToast": {
+ "newVersionAvailable": "새 버전이 있습니다!",
+ "skip": "건너뛰기",
+ "update": "업데이트",
+ "whatsNew": "새로운 기능 보기"
+ },
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "선택된 출력 노드가 없습니다",
@@ -1080,6 +1100,7 @@
"sideToolbar": {
"browseTemplates": "예제 템플릿 탐색",
"downloads": "다운로드",
+ "helpCenter": "도움말 센터",
"logout": "로그아웃",
"modelLibrary": "모델 라이브러리",
"newBlankWorkflow": "새 빈 워크플로 만들기",
@@ -1440,6 +1461,9 @@
"getStarted": "시작하기",
"title": "ComfyUI에 오신 것을 환영합니다"
},
+ "whatsNewPopup": {
+ "learnMore": "자세히 알아보기"
+ },
"workflowService": {
"enterFilename": "파일 이름 입력",
"exportWorkflow": "워크플로 내보내기",
diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json
index 69aec5fec..d2b95c7c0 100644
--- a/src/locales/ru/main.json
+++ b/src/locales/ru/main.json
@@ -381,6 +381,20 @@
"create": "Создать ноду группы",
"enterName": "Введите название"
},
+ "helpCenter": {
+ "clickToLearnMore": "Нажмите, чтобы узнать больше →",
+ "desktopUserGuide": "Руководство пользователя для Desktop",
+ "docs": "Документация",
+ "github": "Github",
+ "helpFeedback": "Помощь и обратная связь",
+ "loadingReleases": "Загрузка релизов...",
+ "more": "Ещё...",
+ "noRecentReleases": "Нет недавних релизов",
+ "openDevTools": "Открыть инструменты разработчика",
+ "reinstall": "Переустановить",
+ "updateAvailable": "Обновить",
+ "whatsNew": "Что нового?"
+ },
"icon": {
"bookmark": "Закладка",
"box": "Коробка",
@@ -872,6 +886,12 @@
},
"title": "Ваше устройство не поддерживается"
},
+ "releaseToast": {
+ "newVersionAvailable": "Доступна новая версия!",
+ "skip": "Пропустить",
+ "update": "Обновить",
+ "whatsNew": "Что нового?"
+ },
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "Выходные узлы не выбраны",
@@ -1080,6 +1100,7 @@
"sideToolbar": {
"browseTemplates": "Просмотреть примеры шаблонов",
"downloads": "Загрузки",
+ "helpCenter": "Центр поддержки",
"logout": "Выйти",
"modelLibrary": "Библиотека моделей",
"newBlankWorkflow": "Создайте новый пустой рабочий процесс",
@@ -1440,6 +1461,9 @@
"getStarted": "Начать",
"title": "Добро пожаловать в ComfyUI"
},
+ "whatsNewPopup": {
+ "learnMore": "Узнать больше"
+ },
"workflowService": {
"enterFilename": "Введите название файла",
"exportWorkflow": "Экспорт рабочего процесса",
diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json
index 9083e1507..7dfb0e6e0 100644
--- a/src/locales/zh/main.json
+++ b/src/locales/zh/main.json
@@ -381,6 +381,20 @@
"create": "创建组节点",
"enterName": "输入名称"
},
+ "helpCenter": {
+ "clickToLearnMore": "点击了解更多 →",
+ "desktopUserGuide": "桌面端用户指南",
+ "docs": "文档",
+ "github": "Github",
+ "helpFeedback": "帮助与反馈",
+ "loadingReleases": "加载发布信息...",
+ "more": "更多...",
+ "noRecentReleases": "没有最近的发布",
+ "openDevTools": "打开开发者工具",
+ "reinstall": "重新安装",
+ "updateAvailable": "更新",
+ "whatsNew": "新功能?"
+ },
"icon": {
"bookmark": "书签",
"box": "盒子",
@@ -872,6 +886,12 @@
},
"title": "您的设备不受支持"
},
+ "releaseToast": {
+ "newVersionAvailable": "新版本可用!",
+ "skip": "跳过",
+ "update": "更新",
+ "whatsNew": "新功能?"
+ },
"selectionToolbox": {
"executeButton": {
"disabledTooltip": "未选择输出节点",
@@ -1080,6 +1100,7 @@
"sideToolbar": {
"browseTemplates": "浏览示例模板",
"downloads": "下载",
+ "helpCenter": "帮助中心",
"logout": "登出",
"modelLibrary": "模型库",
"newBlankWorkflow": "创建空白工作流",
@@ -1440,6 +1461,9 @@
"getStarted": "开始使用",
"title": "欢迎使用 ComfyUI"
},
+ "whatsNewPopup": {
+ "learnMore": "了解更多"
+ },
"workflowService": {
"enterFilename": "输入文件名",
"exportWorkflow": "导出工作流",
diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts
index c93e3e451..da637612e 100644
--- a/src/schemas/apiSchema.ts
+++ b/src/schemas/apiSchema.ts
@@ -472,6 +472,14 @@ const zSettings = z.object({
'pysssss.SnapToGrid': z.boolean(),
/** VHS setting is used for queue video preview support. */
'VHS.AdvancedPreviews': z.string(),
+ /** Release data settings */
+ 'Comfy.Release.Version': z.string(),
+ 'Comfy.Release.Status': z.enum([
+ 'skipped',
+ 'changelog seen',
+ "what's new seen"
+ ]),
+ 'Comfy.Release.Timestamp': z.number(),
/** Settings used for testing */
'test.setting': z.any(),
'main.sub.setting.name': z.any(),
diff --git a/src/services/releaseService.ts b/src/services/releaseService.ts
new file mode 100644
index 000000000..2f2871bd1
--- /dev/null
+++ b/src/services/releaseService.ts
@@ -0,0 +1,120 @@
+import axios, { AxiosError, AxiosResponse } from 'axios'
+import { ref } from 'vue'
+
+import { COMFY_API_BASE_URL } from '@/config/comfyApi'
+import type { components, operations } from '@/types/comfyRegistryTypes'
+import { isAbortError } from '@/utils/typeGuardUtil'
+
+const releaseApiClient = axios.create({
+ baseURL: COMFY_API_BASE_URL,
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+})
+
+// Use generated types from OpenAPI spec
+export type ReleaseNote = components['schemas']['ReleaseNote']
+export type GetReleasesParams =
+ operations['getReleaseNotes']['parameters']['query']
+
+// Use generated error response type
+export type ErrorResponse = components['schemas']['ErrorResponse']
+
+// Release service for fetching release notes
+export const useReleaseService = () => {
+ const isLoading = ref(false)
+ const error = ref(null)
+
+ // No transformation needed - API response matches the generated type
+
+ // Handle API errors with context
+ const handleApiError = (
+ err: unknown,
+ context: string,
+ routeSpecificErrors?: Record
+ ): string => {
+ if (!axios.isAxiosError(err))
+ return err instanceof Error
+ ? `${context}: ${err.message}`
+ : `${context}: Unknown error occurred`
+
+ const axiosError = err as AxiosError
+
+ if (axiosError.response) {
+ const { status, data } = axiosError.response
+
+ if (routeSpecificErrors && routeSpecificErrors[status])
+ return routeSpecificErrors[status]
+
+ switch (status) {
+ case 400:
+ return `Bad request: ${data?.message || 'Invalid input'}`
+ case 401:
+ return 'Unauthorized: Authentication required'
+ case 403:
+ return `Forbidden: ${data?.message || 'Access denied'}`
+ case 404:
+ return `Not found: ${data?.message || 'Resource not found'}`
+ case 500:
+ return `Server error: ${data?.message || 'Internal server error'}`
+ default:
+ return `${context}: ${data?.message || axiosError.message}`
+ }
+ }
+
+ return `${context}: ${axiosError.message}`
+ }
+
+ // Execute API request with error handling
+ const executeApiRequest = async (
+ apiCall: () => Promise>,
+ errorContext: string,
+ routeSpecificErrors?: Record
+ ): Promise => {
+ isLoading.value = true
+ error.value = null
+
+ try {
+ const response = await apiCall()
+ return response.data
+ } catch (err) {
+ // Don't treat cancellations as errors
+ if (isAbortError(err)) return null
+
+ error.value = handleApiError(err, errorContext, routeSpecificErrors)
+ return null
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ // Fetch release notes from API
+ const getReleases = async (
+ params: GetReleasesParams,
+ signal?: AbortSignal
+ ): Promise => {
+ const endpoint = '/releases'
+ const errorContext = 'Failed to get releases'
+ const routeSpecificErrors = {
+ 400: 'Invalid project or version parameter'
+ }
+
+ const apiResponse = await executeApiRequest(
+ () =>
+ releaseApiClient.get(endpoint, {
+ params,
+ signal
+ }),
+ errorContext,
+ routeSpecificErrors
+ )
+
+ return apiResponse
+ }
+
+ return {
+ isLoading,
+ error,
+ getReleases
+ }
+}
diff --git a/src/stores/releaseStore.ts b/src/stores/releaseStore.ts
new file mode 100644
index 000000000..c95a9c957
--- /dev/null
+++ b/src/stores/releaseStore.ts
@@ -0,0 +1,238 @@
+import { defineStore } from 'pinia'
+import { computed, ref } from 'vue'
+
+import { type ReleaseNote, useReleaseService } from '@/services/releaseService'
+import { useSettingStore } from '@/stores/settingStore'
+import { useSystemStatsStore } from '@/stores/systemStatsStore'
+import { compareVersions, stringToLocale } from '@/utils/formatUtil'
+
+// Store for managing release notes
+export const useReleaseStore = defineStore('release', () => {
+ // State
+ const releases = ref([])
+ const isLoading = ref(false)
+ const error = ref(null)
+
+ // Services
+ const releaseService = useReleaseService()
+ const systemStatsStore = useSystemStatsStore()
+ const settingStore = useSettingStore()
+
+ // Current ComfyUI version
+ const currentComfyUIVersion = computed(
+ () => systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
+ )
+
+ // Release data from settings
+ const locale = computed(() => settingStore.get('Comfy.Locale'))
+ const releaseVersion = computed(() =>
+ settingStore.get('Comfy.Release.Version')
+ )
+ const releaseStatus = computed(() => settingStore.get('Comfy.Release.Status'))
+ const releaseTimestamp = computed(() =>
+ settingStore.get('Comfy.Release.Timestamp')
+ )
+
+ // Most recent release
+ const recentRelease = computed(() => {
+ return releases.value[0] ?? null
+ })
+
+ // 3 most recent releases
+ const recentReleases = computed(() => {
+ return releases.value.slice(0, 3)
+ })
+
+ // Helper constants
+ const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 // 3 days
+
+ // New version available?
+ const isNewVersionAvailable = computed(
+ () =>
+ !!recentRelease.value &&
+ compareVersions(
+ recentRelease.value.version,
+ currentComfyUIVersion.value
+ ) > 0
+ )
+
+ const isLatestVersion = computed(
+ () =>
+ !!recentRelease.value &&
+ !compareVersions(recentRelease.value.version, currentComfyUIVersion.value)
+ )
+
+ const hasMediumOrHighAttention = computed(() =>
+ recentReleases.value
+ .slice(0, -1)
+ .some(
+ (release) =>
+ release.attention === 'medium' || release.attention === 'high'
+ )
+ )
+
+ // Show toast if needed
+ const shouldShowToast = computed(() => {
+ if (!isNewVersionAvailable.value) {
+ return false
+ }
+
+ // Skip if low attention
+ if (!hasMediumOrHighAttention.value) {
+ return false
+ }
+
+ // Skip if user already skipped or changelog seen
+ if (
+ releaseVersion.value === recentRelease.value?.version &&
+ !['skipped', 'changelog seen'].includes(releaseStatus.value)
+ ) {
+ return false
+ }
+
+ return true
+ })
+
+ // Show red-dot indicator
+ const shouldShowRedDot = computed(() => {
+ // Already latest → no dot
+ if (!isNewVersionAvailable.value) {
+ return false
+ }
+
+ const { version } = recentRelease.value
+
+ // Changelog seen → clear dot
+ if (
+ releaseVersion.value === version &&
+ releaseStatus.value === 'changelog seen'
+ ) {
+ return false
+ }
+
+ // Attention medium / high (levels 2 & 3)
+ if (hasMediumOrHighAttention.value) {
+ // Persist until changelog is opened
+ return true
+ }
+
+ // Attention low (level 1) and skipped → keep up to 3 d
+ if (
+ releaseVersion.value === version &&
+ releaseStatus.value === 'skipped' &&
+ releaseTimestamp.value &&
+ Date.now() - releaseTimestamp.value >= THREE_DAYS_MS
+ ) {
+ return false
+ }
+
+ // Not skipped → show
+ return true
+ })
+
+ // Show "What's New" popup
+ const shouldShowPopup = computed(() => {
+ if (!isLatestVersion.value) {
+ return false
+ }
+
+ // Hide if already seen
+ if (
+ releaseVersion.value === recentRelease.value.version &&
+ releaseStatus.value === "what's new seen"
+ ) {
+ return false
+ }
+
+ return true
+ })
+
+ // Action handlers for user interactions
+ async function handleSkipRelease(version: string): Promise {
+ if (
+ version !== recentRelease.value?.version ||
+ releaseStatus.value === 'changelog seen'
+ ) {
+ return
+ }
+
+ await settingStore.set('Comfy.Release.Version', version)
+ await settingStore.set('Comfy.Release.Status', 'skipped')
+ await settingStore.set('Comfy.Release.Timestamp', Date.now())
+ }
+
+ async function handleShowChangelog(version: string): Promise {
+ if (version !== recentRelease.value?.version) {
+ return
+ }
+
+ await settingStore.set('Comfy.Release.Version', version)
+ await settingStore.set('Comfy.Release.Status', 'changelog seen')
+ await settingStore.set('Comfy.Release.Timestamp', Date.now())
+ }
+
+ async function handleWhatsNewSeen(version: string): Promise {
+ if (version !== recentRelease.value?.version) {
+ return
+ }
+
+ await settingStore.set('Comfy.Release.Version', version)
+ await settingStore.set('Comfy.Release.Status', "what's new seen")
+ await settingStore.set('Comfy.Release.Timestamp', Date.now())
+ }
+
+ // Fetch releases from API
+ async function fetchReleases(): Promise {
+ if (isLoading.value) return
+
+ isLoading.value = true
+ error.value = null
+
+ try {
+ // Ensure system stats are loaded
+ if (!systemStatsStore.systemStats) {
+ await systemStatsStore.fetchSystemStats()
+ }
+
+ const fetchedReleases = await releaseService.getReleases({
+ project: 'comfyui',
+ current_version: currentComfyUIVersion.value,
+ form_factor: systemStatsStore.getFormFactor(),
+ locale: stringToLocale(locale.value)
+ })
+
+ if (fetchedReleases !== null) {
+ releases.value = fetchedReleases
+ } else if (releaseService.error.value) {
+ error.value = releaseService.error.value
+ }
+ } catch (err) {
+ error.value =
+ err instanceof Error ? err.message : 'Unknown error occurred'
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ // Initialize store
+ async function initialize(): Promise {
+ await fetchReleases()
+ }
+
+ return {
+ releases,
+ isLoading,
+ error,
+ recentRelease,
+ recentReleases,
+ shouldShowToast,
+ shouldShowRedDot,
+ shouldShowPopup,
+ shouldShowUpdateButton: isNewVersionAvailable,
+ handleSkipRelease,
+ handleShowChangelog,
+ handleWhatsNewSeen,
+ fetchReleases,
+ initialize
+ }
+})
diff --git a/src/stores/systemStatsStore.ts b/src/stores/systemStatsStore.ts
index cbac56260..07f28c229 100644
--- a/src/stores/systemStatsStore.ts
+++ b/src/stores/systemStatsStore.ts
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { SystemStats } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
+import { isElectron } from '@/utils/envUtil'
export const useSystemStatsStore = defineStore('systemStats', () => {
const systemStats = ref(null)
@@ -26,10 +27,42 @@ export const useSystemStatsStore = defineStore('systemStats', () => {
}
}
+ function getFormFactor(): string {
+ if (!systemStats.value?.system?.os) {
+ return 'other'
+ }
+
+ const os = systemStats.value.system.os.toLowerCase()
+ const isDesktop = isElectron()
+
+ if (isDesktop) {
+ if (os.includes('windows')) {
+ return 'desktop-windows'
+ }
+ if (os.includes('darwin') || os.includes('mac')) {
+ return 'desktop-mac'
+ }
+ } else {
+ // Git/source installation
+ if (os.includes('windows')) {
+ return 'git-windows'
+ }
+ if (os.includes('darwin') || os.includes('mac')) {
+ return 'git-mac'
+ }
+ if (os.includes('linux')) {
+ return 'git-linux'
+ }
+ }
+
+ return 'other'
+ }
+
return {
systemStats,
isLoading,
error,
- fetchSystemStats
+ fetchSystemStats,
+ getFormFactor
}
})
diff --git a/src/utils/formatUtil.ts b/src/utils/formatUtil.ts
index e80814dc9..1ce04257b 100644
--- a/src/utils/formatUtil.ts
+++ b/src/utils/formatUtil.ts
@@ -1,4 +1,5 @@
import { ResultItem } from '@/schemas/apiSchema'
+import type { operations } from '@/types/comfyRegistryTypes'
export function formatCamelCase(str: string): string {
// Check if the string is camel case
@@ -502,3 +503,46 @@ export function nl2br(text: string): string {
if (!text) return ''
return text.replace(/\n/g, '
')
}
+
+/**
+ * Converts a version string to an anchor-safe format by replacing dots with dashes.
+ * @param version The version string (e.g., "1.0.0", "2.1.3-beta.1")
+ * @returns The anchor-safe version string (e.g., "v1-0-0", "v2-1-3-beta-1")
+ * @example
+ * formatVersionAnchor("1.0.0") // returns "v1-0-0"
+ * formatVersionAnchor("2.1.3-beta.1") // returns "v2-1-3-beta-1"
+ */
+export function formatVersionAnchor(version: string): string {
+ return `v${version.replace(/\./g, '-')}`
+}
+
+/**
+ * Supported locale types for the application (from OpenAPI schema)
+ */
+export type SupportedLocale = NonNullable<
+ operations['getReleaseNotes']['parameters']['query']['locale']
+>
+
+/**
+ * Converts a string to a valid locale type with 'en' as default
+ * @param locale - The locale string to validate and convert
+ * @returns A valid SupportedLocale type, defaults to 'en' if invalid
+ * @example
+ * stringToLocale('fr') // returns 'fr'
+ * stringToLocale('invalid') // returns 'en'
+ * stringToLocale('') // returns 'en'
+ */
+export function stringToLocale(locale: string): SupportedLocale {
+ const supportedLocales: SupportedLocale[] = [
+ 'en',
+ 'es',
+ 'fr',
+ 'ja',
+ 'ko',
+ 'ru',
+ 'zh'
+ ]
+ return supportedLocales.includes(locale as SupportedLocale)
+ ? (locale as SupportedLocale)
+ : 'en'
+}
diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue
index 5d6719a48..9f38dd25c 100644
--- a/src/views/GraphView.vue
+++ b/src/views/GraphView.vue
@@ -15,6 +15,7 @@
+
@@ -218,6 +219,9 @@ onBeforeUnmount(() => {
useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
const { wrapWithErrorHandling, wrapWithErrorHandlingAsync } = useErrorHandling()
+
+// Note: WhatsNew popup functionality is now handled directly by the toast
+
const onGraphReady = () => {
requestIdleCallback(
() => {
diff --git a/tests-ui/tests/services/releaseService.test.ts b/tests-ui/tests/services/releaseService.test.ts
new file mode 100644
index 000000000..57913a5d5
--- /dev/null
+++ b/tests-ui/tests/services/releaseService.test.ts
@@ -0,0 +1,220 @@
+import axios from 'axios'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useReleaseService } from '@/services/releaseService'
+
+// Hoist the mock to avoid hoisting issues
+const mockAxiosInstance = vi.hoisted(() => ({
+ get: vi.fn()
+}))
+
+vi.mock('axios', () => ({
+ default: {
+ create: vi.fn(() => mockAxiosInstance),
+ isAxiosError: vi.fn()
+ }
+}))
+
+describe('useReleaseService', () => {
+ let service: ReturnType
+
+ const mockReleases = [
+ {
+ id: 1,
+ project: 'comfyui' as const,
+ version: '1.2.0',
+ attention: 'high' as const,
+ content: 'New features and improvements',
+ published_at: '2023-12-01T00:00:00Z'
+ }
+ ]
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ service = useReleaseService()
+ })
+
+ it('should initialize with default state', () => {
+ expect(service.isLoading.value).toBe(false)
+ expect(service.error.value).toBeNull()
+ })
+
+ describe('getReleases', () => {
+ it('should fetch releases successfully', async () => {
+ mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
+
+ const result = await service.getReleases({
+ project: 'comfyui',
+ current_version: '1.0.0'
+ })
+
+ expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
+ params: {
+ project: 'comfyui',
+ current_version: '1.0.0'
+ },
+ signal: undefined
+ })
+
+ expect(result).toEqual(mockReleases)
+ expect(service.isLoading.value).toBe(false)
+ expect(service.error.value).toBeNull()
+ })
+
+ it('should fetch releases with form_factor parameter', async () => {
+ mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
+
+ const result = await service.getReleases({
+ project: 'comfyui',
+ current_version: '1.0.0',
+ form_factor: 'desktop-windows'
+ })
+
+ expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
+ params: {
+ project: 'comfyui',
+ current_version: '1.0.0',
+ form_factor: 'desktop-windows'
+ },
+ signal: undefined
+ })
+
+ expect(result).toEqual(mockReleases)
+ })
+
+ it('should pass abort signal when provided', async () => {
+ const abortController = new AbortController()
+ mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
+
+ await service.getReleases({ project: 'comfyui' }, abortController.signal)
+
+ expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
+ params: { project: 'comfyui' },
+ signal: abortController.signal
+ })
+ })
+
+ it('should handle API errors with response', async () => {
+ const errorResponse = {
+ response: {
+ status: 400,
+ data: { message: 'Invalid parameters' }
+ }
+ }
+ mockAxiosInstance.get.mockRejectedValue(errorResponse)
+ vi.mocked(axios.isAxiosError).mockReturnValue(true)
+
+ const result = await service.getReleases({ project: 'comfyui' })
+
+ expect(result).toBeNull()
+ expect(service.error.value).toBe('Invalid project or version parameter')
+ expect(service.isLoading.value).toBe(false)
+ })
+
+ it('should handle 401 errors', async () => {
+ const errorResponse = {
+ response: {
+ status: 401,
+ data: { message: 'Unauthorized' }
+ }
+ }
+ mockAxiosInstance.get.mockRejectedValue(errorResponse)
+
+ const result = await service.getReleases({ project: 'comfyui' })
+
+ expect(result).toBeNull()
+ expect(service.error.value).toBe('Unauthorized: Authentication required')
+ })
+
+ it('should handle 404 errors', async () => {
+ const errorResponse = {
+ response: {
+ status: 404,
+ data: { message: 'Not found' }
+ }
+ }
+ mockAxiosInstance.get.mockRejectedValue(errorResponse)
+
+ const result = await service.getReleases({ project: 'comfyui' })
+
+ expect(result).toBeNull()
+ expect(service.error.value).toBe('Not found: Not found')
+ })
+
+ it('should handle 500 errors', async () => {
+ const errorResponse = {
+ response: {
+ status: 500,
+ data: { message: 'Server error' }
+ }
+ }
+ mockAxiosInstance.get.mockRejectedValue(errorResponse)
+
+ const result = await service.getReleases({ project: 'comfyui' })
+
+ expect(result).toBeNull()
+ expect(service.error.value).toBe('Server error: Server error')
+ })
+
+ it('should handle network errors', async () => {
+ const networkError = new Error('Network Error')
+ mockAxiosInstance.get.mockRejectedValue(networkError)
+
+ const result = await service.getReleases({ project: 'comfyui' })
+
+ expect(result).toBeNull()
+ expect(service.error.value).toBe('Failed to get releases: Network Error')
+ })
+
+ it('should handle abort errors gracefully', async () => {
+ const abortError = {
+ name: 'AbortError',
+ message: 'Request aborted'
+ }
+ mockAxiosInstance.get.mockRejectedValue(abortError)
+
+ const result = await service.getReleases({ project: 'comfyui' })
+
+ expect(result).toBeNull()
+ expect(service.error.value).toContain('Request aborted') // Abort errors are handled
+ })
+
+ it('should handle non-Error objects', async () => {
+ const stringError = 'String error'
+ mockAxiosInstance.get.mockRejectedValue(stringError)
+
+ const result = await service.getReleases({ project: 'comfyui' })
+
+ expect(result).toBeNull()
+ expect(service.error.value).toBe('Failed to get releases: undefined')
+ })
+
+ it('should set loading state correctly', async () => {
+ let resolvePromise: (value: any) => void
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve
+ })
+ mockAxiosInstance.get.mockReturnValue(promise)
+
+ const fetchPromise = service.getReleases({ project: 'comfyui' })
+ expect(service.isLoading.value).toBe(true)
+
+ resolvePromise!({ data: mockReleases })
+ await fetchPromise
+
+ expect(service.isLoading.value).toBe(false)
+ })
+
+ it('should reset error state on new request', async () => {
+ // First request fails
+ mockAxiosInstance.get.mockRejectedValueOnce(new Error('First error'))
+ await service.getReleases({ project: 'comfyui' })
+ expect(service.error.value).toBe('Failed to get releases: First error')
+
+ // Second request succeeds
+ mockAxiosInstance.get.mockResolvedValueOnce({ data: mockReleases })
+ await service.getReleases({ project: 'comfyui' })
+ expect(service.error.value).toBeNull()
+ })
+ })
+})
diff --git a/tests-ui/tests/store/releaseStore.test.ts b/tests-ui/tests/store/releaseStore.test.ts
new file mode 100644
index 000000000..99c826cfa
--- /dev/null
+++ b/tests-ui/tests/store/releaseStore.test.ts
@@ -0,0 +1,289 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useReleaseStore } from '@/stores/releaseStore'
+
+// Mock the dependencies
+vi.mock('@/utils/formatUtil')
+vi.mock('@/services/releaseService')
+vi.mock('@/stores/settingStore')
+vi.mock('@/stores/systemStatsStore')
+
+describe('useReleaseStore', () => {
+ let store: ReturnType
+ let mockReleaseService: any
+ let mockSettingStore: any
+ let mockSystemStatsStore: any
+
+ const mockRelease = {
+ id: 1,
+ project: 'comfyui' as const,
+ version: '1.2.0',
+ content: 'New features and improvements',
+ published_at: '2023-12-01T00:00:00Z',
+ attention: 'high' as const
+ }
+
+ beforeEach(async () => {
+ setActivePinia(createPinia())
+
+ // Reset all mocks
+ vi.clearAllMocks()
+
+ // Setup mock services
+ mockReleaseService = {
+ getReleases: vi.fn(),
+ isLoading: { value: false },
+ error: { value: null }
+ }
+
+ mockSettingStore = {
+ get: vi.fn(),
+ set: vi.fn()
+ }
+
+ mockSystemStatsStore = {
+ systemStats: {
+ system: {
+ comfyui_version: '1.0.0'
+ }
+ },
+ fetchSystemStats: vi.fn(),
+ getFormFactor: vi.fn(() => 'git-windows')
+ }
+
+ // Setup mock implementations
+ const { useReleaseService } = await import('@/services/releaseService')
+ const { useSettingStore } = await import('@/stores/settingStore')
+ const { useSystemStatsStore } = await import('@/stores/systemStatsStore')
+
+ vi.mocked(useReleaseService).mockReturnValue(mockReleaseService)
+ vi.mocked(useSettingStore).mockReturnValue(mockSettingStore)
+ vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore)
+
+ store = useReleaseStore()
+ })
+
+ describe('initial state', () => {
+ it('should initialize with default state', () => {
+ expect(store.releases).toEqual([])
+ expect(store.isLoading).toBe(false)
+ expect(store.error).toBeNull()
+ })
+ })
+
+ describe('computed properties', () => {
+ it('should return most recent release', () => {
+ const olderRelease = {
+ ...mockRelease,
+ id: 2,
+ version: '1.1.0',
+ published_at: '2023-11-01T00:00:00Z'
+ }
+
+ store.releases = [mockRelease, olderRelease]
+ expect(store.recentRelease).toEqual(mockRelease)
+ })
+
+ it('should return 3 most recent releases', () => {
+ const releases = [
+ mockRelease,
+ { ...mockRelease, id: 2, version: '1.1.0' },
+ { ...mockRelease, id: 3, version: '1.0.0' },
+ { ...mockRelease, id: 4, version: '0.9.0' }
+ ]
+
+ store.releases = releases
+ expect(store.recentReleases).toEqual(releases.slice(0, 3))
+ })
+
+ it('should show update button (shouldShowUpdateButton)', async () => {
+ const { compareVersions } = await import('@/utils/formatUtil')
+ vi.mocked(compareVersions).mockReturnValue(1) // newer version available
+
+ store.releases = [mockRelease]
+ expect(store.shouldShowUpdateButton).toBe(true)
+ })
+
+ it('should not show update button when no new version', async () => {
+ const { compareVersions } = await import('@/utils/formatUtil')
+ vi.mocked(compareVersions).mockReturnValue(-1) // current version is newer
+
+ store.releases = [mockRelease]
+ expect(store.shouldShowUpdateButton).toBe(false)
+ })
+ })
+
+ describe('release initialization', () => {
+ it('should fetch releases successfully', async () => {
+ mockReleaseService.getReleases.mockResolvedValue([mockRelease])
+
+ await store.initialize()
+
+ expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
+ project: 'comfyui',
+ current_version: '1.0.0',
+ form_factor: 'git-windows'
+ })
+ expect(store.releases).toEqual([mockRelease])
+ })
+
+ it('should include form_factor in API call', async () => {
+ mockSystemStatsStore.getFormFactor.mockReturnValue('desktop-mac')
+ mockReleaseService.getReleases.mockResolvedValue([mockRelease])
+
+ await store.initialize()
+
+ expect(mockReleaseService.getReleases).toHaveBeenCalledWith({
+ project: 'comfyui',
+ current_version: '1.0.0',
+ form_factor: 'desktop-mac'
+ })
+ })
+
+ it('should handle API errors gracefully', async () => {
+ mockReleaseService.getReleases.mockResolvedValue(null)
+ mockReleaseService.error.value = 'API Error'
+
+ await store.initialize()
+
+ expect(store.releases).toEqual([])
+ expect(store.error).toBe('API Error')
+ })
+
+ it('should handle non-Error objects', async () => {
+ mockReleaseService.getReleases.mockRejectedValue('String error')
+
+ await store.initialize()
+
+ expect(store.error).toBe('Unknown error occurred')
+ })
+
+ it('should set loading state correctly', async () => {
+ let resolvePromise: (value: any) => void
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve
+ })
+
+ mockReleaseService.getReleases.mockReturnValue(promise)
+
+ const initPromise = store.initialize()
+ expect(store.isLoading).toBe(true)
+
+ resolvePromise!([mockRelease])
+ await initPromise
+
+ expect(store.isLoading).toBe(false)
+ })
+
+ it('should fetch system stats if not available', async () => {
+ mockSystemStatsStore.systemStats = null
+ mockReleaseService.getReleases.mockResolvedValue([mockRelease])
+
+ await store.initialize()
+
+ expect(mockSystemStatsStore.fetchSystemStats).toHaveBeenCalled()
+ })
+ })
+
+ describe('action handlers', () => {
+ beforeEach(() => {
+ store.releases = [mockRelease]
+ })
+
+ it('should handle skip release', async () => {
+ await store.handleSkipRelease('1.2.0')
+
+ expect(mockSettingStore.set).toHaveBeenCalledWith(
+ 'Comfy.Release.Version',
+ '1.2.0'
+ )
+ expect(mockSettingStore.set).toHaveBeenCalledWith(
+ 'Comfy.Release.Status',
+ 'skipped'
+ )
+ expect(mockSettingStore.set).toHaveBeenCalledWith(
+ 'Comfy.Release.Timestamp',
+ expect.any(Number)
+ )
+ })
+
+ it('should handle show changelog', async () => {
+ await store.handleShowChangelog('1.2.0')
+
+ expect(mockSettingStore.set).toHaveBeenCalledWith(
+ 'Comfy.Release.Version',
+ '1.2.0'
+ )
+ expect(mockSettingStore.set).toHaveBeenCalledWith(
+ 'Comfy.Release.Status',
+ 'changelog seen'
+ )
+ expect(mockSettingStore.set).toHaveBeenCalledWith(
+ 'Comfy.Release.Timestamp',
+ expect.any(Number)
+ )
+ })
+
+ it('should handle whats new seen', async () => {
+ await store.handleWhatsNewSeen('1.2.0')
+
+ expect(mockSettingStore.set).toHaveBeenCalledWith(
+ 'Comfy.Release.Version',
+ '1.2.0'
+ )
+ expect(mockSettingStore.set).toHaveBeenCalledWith(
+ 'Comfy.Release.Status',
+ "what's new seen"
+ )
+ expect(mockSettingStore.set).toHaveBeenCalledWith(
+ 'Comfy.Release.Timestamp',
+ expect.any(Number)
+ )
+ })
+ })
+
+ describe('popup visibility', () => {
+ it('should show toast for medium/high attention releases', async () => {
+ mockSettingStore.get.mockImplementation((key: string) => {
+ if (key === 'Comfy.Release.Version') return null
+ if (key === 'Comfy.Release.Status') return null
+ return null
+ })
+
+ const { compareVersions } = await import('@/utils/formatUtil')
+ vi.mocked(compareVersions).mockReturnValue(1)
+
+ const mediumRelease = { ...mockRelease, attention: 'medium' as const }
+ store.releases = [
+ mockRelease,
+ mediumRelease,
+ { ...mockRelease, attention: 'low' as const }
+ ]
+
+ expect(store.shouldShowToast).toBe(true)
+ })
+
+ it('should show red dot for new versions', async () => {
+ const { compareVersions } = await import('@/utils/formatUtil')
+ vi.mocked(compareVersions).mockReturnValue(1)
+ mockSettingStore.get.mockReturnValue(null)
+
+ store.releases = [mockRelease]
+
+ expect(store.shouldShowRedDot).toBe(true)
+ })
+
+ it('should show popup for latest version', async () => {
+ mockSystemStatsStore.systemStats.system.comfyui_version = '1.2.0' // Same as release
+ mockSettingStore.get.mockReturnValue(null)
+
+ const { compareVersions } = await import('@/utils/formatUtil')
+ vi.mocked(compareVersions).mockReturnValue(0) // versions are equal (latest version)
+
+ store.releases = [mockRelease]
+
+ expect(store.shouldShowPopup).toBe(true)
+ })
+ })
+})
diff --git a/tests-ui/tests/store/systemStatsStore.test.ts b/tests-ui/tests/store/systemStatsStore.test.ts
new file mode 100644
index 000000000..3376a19c0
--- /dev/null
+++ b/tests-ui/tests/store/systemStatsStore.test.ts
@@ -0,0 +1,322 @@
+import { createPinia, setActivePinia } from 'pinia'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { api } from '@/scripts/api'
+import { useSystemStatsStore } from '@/stores/systemStatsStore'
+import { isElectron } from '@/utils/envUtil'
+
+// Mock the API
+vi.mock('@/scripts/api', () => ({
+ api: {
+ getSystemStats: vi.fn()
+ }
+}))
+
+// Mock the envUtil
+vi.mock('@/utils/envUtil', () => ({
+ isElectron: vi.fn()
+}))
+
+describe('useSystemStatsStore', () => {
+ let store: ReturnType
+
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ store = useSystemStatsStore()
+ vi.clearAllMocks()
+ })
+
+ it('should initialize with null systemStats', () => {
+ expect(store.systemStats).toBeNull()
+ expect(store.isLoading).toBe(false)
+ expect(store.error).toBeNull()
+ })
+
+ describe('fetchSystemStats', () => {
+ it('should fetch system stats successfully', async () => {
+ const mockStats = {
+ system: {
+ os: 'Windows',
+ python_version: '3.10.0',
+ embedded_python: false,
+ comfyui_version: '1.0.0',
+ pytorch_version: '2.0.0',
+ argv: [],
+ ram_total: 16000000000,
+ ram_free: 8000000000
+ },
+ devices: []
+ }
+
+ vi.mocked(api.getSystemStats).mockResolvedValue(mockStats)
+
+ await store.fetchSystemStats()
+
+ expect(store.systemStats).toEqual(mockStats)
+ expect(store.isLoading).toBe(false)
+ expect(store.error).toBeNull()
+ expect(api.getSystemStats).toHaveBeenCalled()
+ })
+
+ it('should handle API errors', async () => {
+ const error = new Error('API Error')
+ vi.mocked(api.getSystemStats).mockRejectedValue(error)
+
+ await store.fetchSystemStats()
+
+ expect(store.systemStats).toBeNull()
+ expect(store.isLoading).toBe(false)
+ expect(store.error).toBe('API Error')
+ })
+
+ it('should handle non-Error objects', async () => {
+ vi.mocked(api.getSystemStats).mockRejectedValue('String error')
+
+ await store.fetchSystemStats()
+
+ expect(store.error).toBe('An error occurred while fetching system stats')
+ })
+
+ it('should set loading state correctly', async () => {
+ let resolvePromise: (value: any) => void = () => {}
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve
+ })
+ vi.mocked(api.getSystemStats).mockReturnValue(promise)
+
+ const fetchPromise = store.fetchSystemStats()
+ expect(store.isLoading).toBe(true)
+
+ resolvePromise({})
+ await fetchPromise
+
+ expect(store.isLoading).toBe(false)
+ })
+ })
+
+ describe('getFormFactor', () => {
+ beforeEach(() => {
+ // Reset systemStats for each test
+ store.systemStats = null
+ })
+
+ it('should return "other" when systemStats is null', () => {
+ expect(store.getFormFactor()).toBe('other')
+ })
+
+ it('should return "other" when os is not available', () => {
+ store.systemStats = {
+ system: {
+ python_version: '3.10.0',
+ embedded_python: false,
+ comfyui_version: '1.0.0',
+ pytorch_version: '2.0.0',
+ argv: [],
+ ram_total: 16000000000,
+ ram_free: 8000000000
+ } as any,
+ devices: []
+ }
+
+ expect(store.getFormFactor()).toBe('other')
+ })
+
+ describe('desktop environment (Electron)', () => {
+ beforeEach(() => {
+ vi.mocked(isElectron).mockReturnValue(true)
+ })
+
+ it('should return "desktop-windows" for Windows desktop', () => {
+ store.systemStats = {
+ system: {
+ os: 'Windows 11',
+ python_version: '3.10.0',
+ embedded_python: false,
+ comfyui_version: '1.0.0',
+ pytorch_version: '2.0.0',
+ argv: [],
+ ram_total: 16000000000,
+ ram_free: 8000000000
+ },
+ devices: []
+ }
+
+ expect(store.getFormFactor()).toBe('desktop-windows')
+ })
+
+ it('should return "desktop-mac" for macOS desktop', () => {
+ store.systemStats = {
+ system: {
+ os: 'Darwin 22.0.0',
+ python_version: '3.10.0',
+ embedded_python: false,
+ comfyui_version: '1.0.0',
+ pytorch_version: '2.0.0',
+ argv: [],
+ ram_total: 16000000000,
+ ram_free: 8000000000
+ },
+ devices: []
+ }
+
+ expect(store.getFormFactor()).toBe('desktop-mac')
+ })
+
+ it('should return "desktop-mac" for Mac desktop', () => {
+ store.systemStats = {
+ system: {
+ os: 'Mac OS X 13.0',
+ python_version: '3.10.0',
+ embedded_python: false,
+ comfyui_version: '1.0.0',
+ pytorch_version: '2.0.0',
+ argv: [],
+ ram_total: 16000000000,
+ ram_free: 8000000000
+ },
+ devices: []
+ }
+
+ expect(store.getFormFactor()).toBe('desktop-mac')
+ })
+
+ it('should return "other" for unknown desktop OS', () => {
+ store.systemStats = {
+ system: {
+ os: 'Linux',
+ python_version: '3.10.0',
+ embedded_python: false,
+ comfyui_version: '1.0.0',
+ pytorch_version: '2.0.0',
+ argv: [],
+ ram_total: 16000000000,
+ ram_free: 8000000000
+ },
+ devices: []
+ }
+
+ expect(store.getFormFactor()).toBe('other')
+ })
+ })
+
+ describe('git environment (non-Electron)', () => {
+ beforeEach(() => {
+ vi.mocked(isElectron).mockReturnValue(false)
+ })
+
+ it('should return "git-windows" for Windows git', () => {
+ store.systemStats = {
+ system: {
+ os: 'Windows 11',
+ python_version: '3.10.0',
+ embedded_python: false,
+ comfyui_version: '1.0.0',
+ pytorch_version: '2.0.0',
+ argv: [],
+ ram_total: 16000000000,
+ ram_free: 8000000000
+ },
+ devices: []
+ }
+
+ expect(store.getFormFactor()).toBe('git-windows')
+ })
+
+ it('should return "git-mac" for macOS git', () => {
+ store.systemStats = {
+ system: {
+ os: 'Darwin 22.0.0',
+ python_version: '3.10.0',
+ embedded_python: false,
+ comfyui_version: '1.0.0',
+ pytorch_version: '2.0.0',
+ argv: [],
+ ram_total: 16000000000,
+ ram_free: 8000000000
+ },
+ devices: []
+ }
+
+ expect(store.getFormFactor()).toBe('git-mac')
+ })
+
+ it('should return "git-linux" for Linux git', () => {
+ store.systemStats = {
+ system: {
+ os: 'linux Ubuntu 22.04',
+ python_version: '3.10.0',
+ embedded_python: false,
+ comfyui_version: '1.0.0',
+ pytorch_version: '2.0.0',
+ argv: [],
+ ram_total: 16000000000,
+ ram_free: 8000000000
+ },
+ devices: []
+ }
+
+ expect(store.getFormFactor()).toBe('git-linux')
+ })
+
+ it('should return "other" for unknown git OS', () => {
+ store.systemStats = {
+ system: {
+ os: 'FreeBSD',
+ python_version: '3.10.0',
+ embedded_python: false,
+ comfyui_version: '1.0.0',
+ pytorch_version: '2.0.0',
+ argv: [],
+ ram_total: 16000000000,
+ ram_free: 8000000000
+ },
+ devices: []
+ }
+
+ expect(store.getFormFactor()).toBe('other')
+ })
+ })
+
+ describe('case insensitive OS detection', () => {
+ beforeEach(() => {
+ vi.mocked(isElectron).mockReturnValue(false)
+ })
+
+ it('should handle uppercase OS names', () => {
+ store.systemStats = {
+ system: {
+ os: 'WINDOWS',
+ python_version: '3.10.0',
+ embedded_python: false,
+ comfyui_version: '1.0.0',
+ pytorch_version: '2.0.0',
+ argv: [],
+ ram_total: 16000000000,
+ ram_free: 8000000000
+ },
+ devices: []
+ }
+
+ expect(store.getFormFactor()).toBe('git-windows')
+ })
+
+ it('should handle mixed case OS names', () => {
+ store.systemStats = {
+ system: {
+ os: 'LiNuX',
+ python_version: '3.10.0',
+ embedded_python: false,
+ comfyui_version: '1.0.0',
+ pytorch_version: '2.0.0',
+ argv: [],
+ ram_total: 16000000000,
+ ram_free: 8000000000
+ },
+ devices: []
+ }
+
+ expect(store.getFormFactor()).toBe('git-linux')
+ })
+ })
+ })
+})