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') + }) + }) + }) +})