diff --git a/package-lock.json b/package-lock.json index 7c04bb4c8..389b6e742 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "pinia": "^2.1.7", "primeicons": "^7.0.0", "primevue": "^4.2.5", + "semver": "^7.7.2", "three": "^0.170.0", "tiptap-markdown": "^0.8.10", "vue": "^3.5.13", @@ -62,6 +63,7 @@ "@types/fs-extra": "^11.0.4", "@types/lodash": "^4.17.6", "@types/node": "^20.14.8", + "@types/semver": "^7.7.0", "@types/three": "^0.169.0", "@vitejs/plugin-vue": "^5.1.4", "@vue/test-utils": "^2.4.6", @@ -557,6 +559,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", @@ -601,6 +613,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", @@ -622,6 +644,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", @@ -4522,6 +4554,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/semver": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stats.js": { "version": "0.17.3", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", @@ -4753,19 +4792,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.0.0.tgz", @@ -6536,19 +6562,6 @@ "dev": true, "license": "MIT" }, - "node_modules/conf/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/confbox": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz", @@ -7448,19 +7461,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/editorconfig/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7851,19 +7851,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-vue/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-vue/node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -10725,19 +10712,6 @@ "node": ">=14" } }, - "node_modules/langsmith/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/latest-version": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-9.0.0.tgz", @@ -12687,19 +12661,6 @@ "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", "dev": true }, - "node_modules/package-json/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/package-manager-detector": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.0.tgz", @@ -14342,12 +14303,15 @@ "dev": true }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -16206,19 +16170,6 @@ "url": "https://github.com/yeoman/update-notifier?sponsor=1" } }, - "node_modules/update-notifier/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -17038,19 +16989,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/vue-eslint-parser/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/vue-i18n": { "version": "9.14.3", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.3.tgz", @@ -17103,19 +17041,6 @@ "typescript": ">=5.0.0" } }, - "node_modules/vue-tsc/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/vuefire": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/vuefire/-/vuefire-3.2.1.tgz", diff --git a/package.json b/package.json index bcac4351e..31fbfc623 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@types/fs-extra": "^11.0.4", "@types/lodash": "^4.17.6", "@types/node": "^20.14.8", + "@types/semver": "^7.7.0", "@types/three": "^0.169.0", "@vitejs/plugin-vue": "^5.1.4", "@vue/test-utils": "^2.4.6", @@ -105,6 +106,7 @@ "pinia": "^2.1.7", "primeicons": "^7.0.0", "primevue": "^4.2.5", + "semver": "^7.7.2", "three": "^0.170.0", "tiptap-markdown": "^0.8.10", "vue": "^3.5.13", diff --git a/src/App.vue b/src/App.vue index 85b36240c..b99ce915e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,11 +16,13 @@ import { computed, onMounted } from 'vue' import GlobalDialog from '@/components/dialog/GlobalDialog.vue' import config from '@/config' +import { useConflictDetection } from '@/composables/useConflictDetection' import { useWorkspaceStore } from '@/stores/workspaceStore' import { electronAPI, isElectron } from './utils/envUtil' const workspaceStore = useWorkspaceStore() +const conflictDetection = useConflictDetection() const isLoading = computed(() => workspaceStore.spinner) const handleKey = (e: KeyboardEvent) => { workspaceStore.shiftDown = e.shiftKey @@ -47,5 +49,9 @@ onMounted(() => { if (isElectron()) { document.addEventListener('contextmenu', showContextMenu) } + + // Initialize conflict detection in background + // This runs async and doesn't block UI setup + void conflictDetection.initializeConflictDetection() }) diff --git a/src/composables/useConflictDetection.ts b/src/composables/useConflictDetection.ts new file mode 100644 index 000000000..977762a03 --- /dev/null +++ b/src/composables/useConflictDetection.ts @@ -0,0 +1,1096 @@ +import { computed, getCurrentInstance, onUnmounted, readonly, ref } from 'vue' + +import config from '@/config' +import { useComfyManagerService } from '@/services/comfyManagerService' +import { useComfyRegistryService } from '@/services/comfyRegistryService' +import { useSystemStatsStore } from '@/stores/systemStatsStore' +import type { SystemStats } from '@/types' +import type { components } from '@/types/comfyRegistryTypes' +import type { + ConflictDetail, + ConflictDetectionResponse, + ConflictDetectionResult, + ConflictDetectionSummary, + ConflictType, + NodePackRequirements, + RecommendedAction, + SupportedAccelerator, + SupportedOS, + SystemEnvironment +} from '@/types/conflictDetectionTypes' +import type { components as ManagerComponents } from '@/types/generatedManagerTypes' +import { + cleanVersion, + describeVersionRange, + satisfiesVersion +} from '@/utils/versionUtil' + +/** + * Composable for conflict detection system. + * Error-resilient and asynchronous to avoid affecting other components. + */ +export function useConflictDetection() { + // State management + const isDetecting = ref(false) + const lastDetectionTime = ref(null) + const detectionError = ref(null) + + // System environment information + const systemEnvironment = ref(null) + + // Conflict detection results + const detectionResults = ref([]) + const detectionSummary = ref(null) + + // Registry API request cancellation + const abortController = ref(null) + + // Computed properties + const hasConflicts = computed(() => + detectionResults.value.some((result) => result.has_conflict) + ) + + const conflictedPackages = computed(() => + detectionResults.value.filter((result) => result.has_conflict) + ) + + const bannedPackages = computed(() => + detectionResults.value.filter((result) => + result.conflicts.some((conflict) => conflict.type === 'banned') + ) + ) + + const securityPendingPackages = computed(() => + detectionResults.value.filter((result) => + result.conflicts.some((conflict) => conflict.type === 'security_pending') + ) + ) + + const criticalConflicts = computed(() => + detectionResults.value.flatMap((result) => + result.conflicts.filter((conflict) => conflict.severity === 'error') + ) + ) + + /** + * Collects current system environment information. + * Continues with default values even if errors occur. + * @returns Promise that resolves to system environment information + */ + async function detectSystemEnvironment(): Promise { + try { + // Get system stats from store (primary source of system information) + const systemStatsStore = useSystemStatsStore() + await systemStatsStore.fetchSystemStats() + + // Fetch version information from backend (with error resilience) + const [frontendVersion] = await Promise.allSettled([ + fetchFrontendVersion() + ]) + + // Extract system information from system stats + const systemStats = systemStatsStore.systemStats + const comfyuiVersion = systemStats?.system?.comfyui_version || 'unknown' + const pythonVersion = systemStats?.system?.python_version || 'unknown' + + // Use system stats for OS detection (more accurate than browser detection) + const systemOS = systemStats?.system?.os || 'unknown' + + // Extract architecture from system stats device information + const architecture = extractArchitectureFromSystemStats(systemStats) + + // Detect GPU/accelerator information from system stats + const acceleratorInfo = extractAcceleratorInfo(systemStats) + + // Enhanced OS detection using multiple sources + const detectedOS = detectOSFromSystemStats(systemOS, systemStats) + + const environment: SystemEnvironment = { + // Version information (use 'unknown' on failure) + comfyui_version: comfyuiVersion, + frontend_version: + frontendVersion.status === 'fulfilled' + ? frontendVersion.value + : 'unknown', + python_version: pythonVersion, + + // Platform information (from system stats) + os: detectedOS, + platform_details: systemOS, + architecture: architecture, + + // GPU/accelerator information + available_accelerators: acceleratorInfo.available, + primary_accelerator: acceleratorInfo.primary, + gpu_memory_mb: acceleratorInfo.memory_mb, + + // Runtime information + node_env: import.meta.env.MODE as 'development' | 'production', + user_agent: navigator.userAgent + } + + systemEnvironment.value = environment + console.log( + '[ConflictDetection] System environment detection completed:', + environment + ) + return environment + } catch (error) { + console.warn( + '[ConflictDetection] Error during system environment detection:', + error + ) + + // Try to get frontend version even in fallback mode + let frontendVersion = 'unknown' + try { + frontendVersion = await fetchFrontendVersion() + } catch { + frontendVersion = 'unknown' + } + + // Provide basic environment information even on error + const fallbackEnvironment: SystemEnvironment = { + comfyui_version: 'unknown', + frontend_version: frontendVersion, + python_version: 'unknown', + os: detectOSFromSystemStats(navigator.platform), + platform_details: navigator.platform, + architecture: getArchitecture(), + available_accelerators: ['CPU'], + primary_accelerator: 'CPU', + node_env: import.meta.env.MODE as 'development' | 'production', + user_agent: navigator.userAgent + } + + systemEnvironment.value = fallbackEnvironment + return fallbackEnvironment + } + } + + /** + * Fetches requirement information for installed packages using Registry Store. + * + * This function combines local installation data with Registry API compatibility metadata + * using the established store layer pattern with caching and batch requests. + * + * Process + * 1. Get locally installed packages + * 2. Batch fetch Registry data using store layer + * 3. Combine local + Registry data + * 4. Extract compatibility requirements + * + * @returns Promise that resolves to array of node pack requirements + */ + async function fetchPackageRequirements(): Promise { + try { + // Step 1: Get locally installed packages + const comfyManagerService = useComfyManagerService() + const installedNodes: + | ManagerComponents['schemas']['InstalledPacksResponse'] + | null = await comfyManagerService.listInstalledPacks() + + if (!installedNodes) { + console.warn( + '[ConflictDetection] Unable to fetch installed package information' + ) + return [] + } + + // Step 2: Get Registry service for individual API calls + const registryService = useComfyRegistryService() + + // Step 3: Setup abort controller for request cancellation + abortController.value = new AbortController() + + // Step 4: Fetch version-specific data in chunks to avoid overwhelming the Registry API + // - Each chunk processes up to 30 packages concurrently + // - Results are stored in versionDataMap for later use + const entries = Object.entries(installedNodes) + const chunkSize = 30 // 청크 크기 + const versionDataMap = new Map< + string, + components['schemas']['NodeVersion'] + >() + + for (let i = 0; i < entries.length; i += chunkSize) { + const chunk = entries.slice(i, i + chunkSize) + + const fetchTasks = chunk.map(async ([packageName, nodeInfo]) => { + const typedNodeInfo: ManagerComponents['schemas']['ManagerPackInstalled'] = + nodeInfo + const version = typedNodeInfo.ver || 'latest' + + try { + const versionData = await registryService.getPackByVersion( + packageName, + version, + abortController.value?.signal + ) + + if (versionData) { + versionDataMap.set(packageName, versionData) + } + } catch (error) { + console.warn( + `[ConflictDetection] Failed to fetch version data for ${packageName}@${version}:`, + error + ) + } + }) + + await Promise.allSettled(fetchTasks) + } + + // Step 5: Combine local installation data with Registry version data + const requirements: NodePackRequirements[] = [] + + for (const [packageName, nodeInfo] of Object.entries(installedNodes)) { + const typedNodeInfo: ManagerComponents['schemas']['ManagerPackInstalled'] = + nodeInfo + const versionData = versionDataMap.get(packageName) + + if (versionData) { + // Combine local installation data with version-specific Registry data + const requirement: NodePackRequirements = { + // Basic package info + package_id: packageName, + package_name: packageName, // We don't need to fetch node info separately + installed_version: typedNodeInfo.ver || 'unknown', + is_enabled: typedNodeInfo.enabled, + + // Version-specific compatibility data + supported_comfyui_version: versionData.supported_comfyui_version, + supported_comfyui_frontend_version: + versionData.supported_comfyui_frontend_version, + supported_os: normalizeOSValues(versionData.supported_os), + supported_accelerators: + versionData.supported_accelerators as SupportedAccelerator[], + dependencies: versionData.dependencies || [], + + // Status information + registry_status: undefined, // Node status - not critical for conflict detection + version_status: versionData.status, + is_banned: + versionData.status === 'NodeVersionStatusBanned' || + !typedNodeInfo.enabled, + ban_reason: + versionData.status === 'NodeVersionStatusBanned' + ? 'Version is banned in Registry' + : !typedNodeInfo.enabled + ? 'Package is disabled locally' + : undefined, + + // Metadata + registry_fetch_time: new Date().toISOString(), + has_registry_data: true + } + + requirements.push(requirement) + } else { + console.warn( + `[ConflictDetection] No Registry data found for ${packageName}, using fallback` + ) + + // Create fallback requirement without Registry data + const fallbackRequirement: NodePackRequirements = { + package_id: packageName, + package_name: packageName, + installed_version: typedNodeInfo.ver || 'unknown', + is_enabled: typedNodeInfo.enabled, + is_banned: !typedNodeInfo.enabled, + ban_reason: !typedNodeInfo.enabled + ? 'Package is disabled locally' + : undefined, + registry_fetch_time: new Date().toISOString(), + has_registry_data: false + } + + requirements.push(fallbackRequirement) + } + } + + return requirements + } catch (error) { + console.warn( + '[ConflictDetection] Failed to fetch package requirements:', + error + ) + return [] + } + } + + /** + * Detects conflicts for an individual package using Registry API data. + * + * @param packageReq Package requirements from Registry + * @param sysEnv Current system environment + * @returns Conflict detection result for the package + */ + function detectPackageConflicts( + packageReq: NodePackRequirements, + sysEnv: SystemEnvironment + ): ConflictDetectionResult { + const conflicts: ConflictDetail[] = [] + + // Helper function to check if a value indicates "compatible with all" + const isCompatibleWithAll = (value: any): boolean => { + if (value === null || value === undefined) return true + if (typeof value === 'string' && value.trim() === '') return true + if (Array.isArray(value) && value.length === 0) return true + return false + } + + // 1. ComfyUI version conflict check + if ( + packageReq.has_registry_data && + !isCompatibleWithAll(packageReq.supported_comfyui_version) + ) { + const versionConflict = checkVersionConflict( + 'comfyui_version', + sysEnv.comfyui_version, + packageReq.supported_comfyui_version! + ) + if (versionConflict) conflicts.push(versionConflict) + } + + // 2. Frontend version conflict check + if ( + packageReq.has_registry_data && + !isCompatibleWithAll(packageReq.supported_comfyui_frontend_version) + ) { + const versionConflict = checkVersionConflict( + 'frontend_version', + sysEnv.frontend_version, + packageReq.supported_comfyui_frontend_version! + ) + if (versionConflict) conflicts.push(versionConflict) + } + + // 3. OS compatibility check + if ( + packageReq.has_registry_data && + !isCompatibleWithAll(packageReq.supported_os) + ) { + const osConflict = checkOSConflict(packageReq.supported_os!, sysEnv.os) + if (osConflict) conflicts.push(osConflict) + } + + // 4. Accelerator compatibility check + if ( + packageReq.has_registry_data && + !isCompatibleWithAll(packageReq.supported_accelerators) + ) { + const acceleratorConflict = checkAcceleratorConflict( + packageReq.supported_accelerators!, + sysEnv.available_accelerators + ) + if (acceleratorConflict) conflicts.push(acceleratorConflict) + } + + // 5. Banned package check + if (packageReq.is_banned) { + conflicts.push({ + type: 'banned', + severity: 'error', + description: `Package is banned: ${packageReq.ban_reason || 'Unknown reason'}`, + current_value: 'installed', + required_value: 'not_banned', + resolution_steps: ['Remove package', 'Find alternative package'] + }) + } + + // 6. Registry data availability check + if (!packageReq.has_registry_data) { + conflicts.push({ + type: 'security_pending', + severity: 'warning', + description: + 'Registry data not available - compatibility cannot be verified', + current_value: 'no_registry_data', + required_value: 'registry_data_available', + resolution_steps: [ + 'Check if package exists in Registry', + 'Verify package name is correct', + 'Try again later if Registry is temporarily unavailable' + ] + }) + } + + // Generate result + const hasConflict = conflicts.length > 0 + const canAutoResolve = conflicts.every( + (c) => c.resolution_steps && c.resolution_steps.length > 0 + ) + + return { + package_id: packageReq.package_id, + package_name: packageReq.package_name, + has_conflict: hasConflict, + conflicts, + is_compatible: !hasConflict, + can_auto_resolve: canAutoResolve, + recommended_action: determineRecommendedAction(conflicts) + } + } + + /** + * Performs complete conflict detection. + * @returns Promise that resolves to conflict detection response + */ + async function performConflictDetection(): Promise { + if (isDetecting.value) { + console.log('[ConflictDetection] Already detecting, skipping') + return { + success: false, + error_message: 'Already detecting conflicts', + summary: detectionSummary.value!, + results: detectionResults.value + } + } + + isDetecting.value = true + detectionError.value = null + const startTime = Date.now() + + try { + // 1. Collect system environment information + const sysEnv = await detectSystemEnvironment() + + // 2. Collect package requirement information + const packageRequirements = await fetchPackageRequirements() + + // 3. Detect conflicts for each package (parallel processing) + const conflictDetectionTasks = packageRequirements.map( + async (packageReq) => { + try { + return detectPackageConflicts(packageReq, sysEnv) + } catch (error) { + console.warn( + `[ConflictDetection] Failed to detect conflicts for package ${packageReq.package_name}:`, + error + ) + // Return null for failed packages, will be filtered out + return null + } + } + ) + + const conflictResults = await Promise.allSettled(conflictDetectionTasks) + const results: ConflictDetectionResult[] = conflictResults + .map((result) => (result.status === 'fulfilled' ? result.value : null)) + .filter((result): result is ConflictDetectionResult => result !== null) + + // 4. Generate summary information + const summary = generateSummary(results, Date.now() - startTime) + + // 5. Update state + detectionResults.value = results + detectionSummary.value = summary + lastDetectionTime.value = new Date().toISOString() + + console.log('[ConflictDetection] Conflict detection completed:', summary) + + const response: ConflictDetectionResponse = { + success: true, + summary, + results, + detected_system_environment: sysEnv + } + + return response + } catch (error) { + console.error( + '[ConflictDetection] Error during conflict detection:', + error + ) + detectionError.value = + error instanceof Error ? error.message : String(error) + + return { + success: false, + error_message: detectionError.value, + summary: detectionSummary.value || getEmptySummary(), + results: [] + } + } finally { + isDetecting.value = false + // Clear abort controller to prevent memory leaks + if (abortController.value) { + abortController.value = null + } + } + } + + /** + * Error-resilient initialization (called on app mount). + * Async function that doesn't block UI setup. + */ + async function initializeConflictDetection(): Promise { + try { + await performConflictDetection() + } catch (error) { + console.warn( + '[ConflictDetection] Error during initialization (ignored):', + error + ) + // Errors do not affect other parts of the app + } + } + + // Cleanup function for request cancellation + function cancelRequests(): void { + if (abortController.value) { + abortController.value.abort() + abortController.value = null + } + } + + // Auto-cleanup on component unmount + // Only register lifecycle hooks if we're in a Vue component context + const instance = getCurrentInstance() + if (instance) { + onUnmounted(() => { + cancelRequests() + }) + } + + // Helper functions (implementations at the bottom of the file) + + return { + // State + isDetecting: readonly(isDetecting), + lastDetectionTime: readonly(lastDetectionTime), + detectionError: readonly(detectionError), + systemEnvironment: readonly(systemEnvironment), + detectionResults: readonly(detectionResults), + detectionSummary: readonly(detectionSummary), + + // Computed + hasConflicts, + conflictedPackages, + bannedPackages, + securityPendingPackages, + criticalConflicts, + + // Methods + performConflictDetection, + detectSystemEnvironment, + initializeConflictDetection, + cancelRequests + } +} + +// Helper Functions Implementation + +/** + * Fetches frontend version from config. + * @returns Promise that resolves to frontend version string + */ +async function fetchFrontendVersion(): Promise { + try { + // Get frontend version from vite build-time constant or fallback to config + return config.app_version || import.meta.env.VITE_APP_VERSION || 'unknown' + } catch { + return 'unknown' + } +} + +/** + * Detects system architecture from user agent. + * Note: Browser architecture detection has limitations and may not be 100% accurate. + * @returns Architecture string + */ +function getArchitecture(): string { + const ua = navigator.userAgent.toLowerCase() + if (ua.includes('arm64') || ua.includes('aarch64')) return 'arm64' + if (ua.includes('arm')) return 'arm' + if (ua.includes('x86_64') || ua.includes('x64')) return 'x64' + if (ua.includes('x86')) return 'x86' + return 'unknown' +} + +/** + * Normalizes OS values from Registry API to match our SupportedOS type. + * Registry Admin guide specifies: Windows, macOS, Linux + * @param osValues OS values from Registry API + * @returns Normalized OS values + */ +function normalizeOSValues(osValues: string[] | undefined): SupportedOS[] { + if (!osValues || osValues.length === 0) { + return [] + } + + return osValues.map((os) => { + // Map to standard Registry values (case-sensitive) + if (os === 'Windows' || os.toLowerCase().includes('win')) { + return 'Windows' + } + if (os === 'macOS' || os.toLowerCase().includes('mac') || os === 'darwin') { + return 'macOS' + } + if (os === 'Linux' || os.toLowerCase().includes('linux')) { + return 'Linux' + } + if (os.toLowerCase() === 'any') { + return 'any' + } + + // Return as-is if it matches standard format + return os as SupportedOS + }) +} + +/** + * Detects operating system from system stats OS string and additional system information. + * @param systemOS OS string from system stats API + * @param systemStats Full system stats object for additional context + * @returns Operating system type + */ +function detectOSFromSystemStats( + systemOS: string, + systemStats?: SystemStats | null +): SupportedOS { + const os = systemOS.toLowerCase() + + // Handle specific OS strings (return Registry standard format) + if (os.includes('darwin') || os.includes('mac')) return 'macOS' + if (os.includes('linux')) return 'Linux' + if (os.includes('win') || os === 'nt') return 'Windows' + + // Handle Python's os.name values + if (os === 'posix') { + // posix could be macOS or Linux, need additional detection + + // Method 1: Check for MPS device (Metal Performance Shaders = macOS) + if (systemStats?.devices) { + const hasMpsDevice = systemStats.devices.some( + (device) => device.type === 'mps' + ) + if (hasMpsDevice) { + return 'macOS' // Registry standard format + } + } + + // Method 2: Check Python version string for platform hints + if (systemStats?.system?.python_version) { + const pythonVersion = systemStats.system.python_version.toLowerCase() + if (pythonVersion.includes('darwin')) return 'macOS' + if (pythonVersion.includes('linux')) return 'Linux' + } + + // Method 3: Check user agent as fallback + const userAgent = navigator.userAgent.toLowerCase() + if (userAgent.includes('mac')) return 'macOS' + if (userAgent.includes('linux')) return 'Linux' + + // Default to 'any' if we can't determine + return 'any' + } + + return 'any' +} + +/** + * Extracts architecture information from system stats. + * @param systemStats System stats data from API + * @returns Architecture string + */ +function extractArchitectureFromSystemStats( + systemStats: SystemStats | null +): string { + try { + if (systemStats?.devices && systemStats.devices.length > 0) { + // Check if we have MPS device (indicates Apple Silicon) + const hasMpsDevice = systemStats.devices.some( + (device) => device.type === 'mps' + ) + + if (hasMpsDevice) { + // MPS is only available on Apple Silicon Macs + return 'arm64' + } + + // Check device names for architecture hints (fallback) + for (const device of systemStats.devices) { + if (!device?.name || typeof device.name !== 'string') { + continue + } + + const deviceName = device.name.toLowerCase() + + // Apple Silicon detection + if ( + deviceName.includes('apple m1') || + deviceName.includes('apple m2') || + deviceName.includes('apple m3') || + deviceName.includes('apple m4') + ) { + return 'arm64' + } + + // Intel/AMD detection + if ( + deviceName.includes('intel') || + deviceName.includes('amd') || + deviceName.includes('nvidia') || + deviceName.includes('geforce') || + deviceName.includes('radeon') + ) { + return 'x64' + } + } + } + + // Fallback to basic User-Agent detection if system stats don't provide clear info + return getArchitecture() + } catch (error) { + console.warn( + '[ConflictDetection] Failed to extract architecture from system stats:', + error + ) + return getArchitecture() + } +} + +/** + * Extracts accelerator information from system stats. + * @param systemStats System stats data from store + * @returns Accelerator information object + */ +function extractAcceleratorInfo(systemStats: SystemStats | null): { + available: SupportedAccelerator[] + primary: SupportedAccelerator + memory_mb?: number +} { + try { + if (systemStats?.devices && systemStats.devices.length > 0) { + const accelerators = new Set() + let primaryDevice: SupportedAccelerator = 'CPU' + let totalMemory = 0 + let maxDevicePriority = 0 + + // Device type priority (higher = better) + const getDevicePriority = (type: string): number => { + switch (type.toLowerCase()) { + case 'cuda': + return 5 + case 'mps': + return 4 + case 'rocm': + return 3 + case 'xpu': + return 2 // Intel GPU + case 'npu': + return 1 // Neural Processing Unit + case 'mlu': + return 1 // Cambricon MLU + case 'cpu': + return 0 + default: + return 0 + } + } + + // Process all devices + for (const device of systemStats.devices) { + const deviceType = device.type.toLowerCase() + const priority = getDevicePriority(deviceType) + + // Map device type to SupportedAccelerator (Registry standard format) + let acceleratorType: SupportedAccelerator = 'CPU' + if (deviceType === 'cuda') { + acceleratorType = 'CUDA' + } else if (deviceType === 'mps') { + acceleratorType = 'Metal' // MPS = Metal Performance Shaders + } else if (deviceType === 'rocm') { + acceleratorType = 'ROCm' + } + + accelerators.add(acceleratorType) + + // Update primary device if this one has higher priority + if (priority > maxDevicePriority) { + primaryDevice = acceleratorType + maxDevicePriority = priority + } + + // Accumulate memory from all devices + if (device.vram_total) { + totalMemory += device.vram_total + } + } + + accelerators.add('CPU') // CPU is always available + + return { + available: Array.from(accelerators), + primary: primaryDevice, + memory_mb: + totalMemory > 0 ? Math.round(totalMemory / 1024 / 1024) : undefined + } + } + } catch (error) { + console.warn( + '[ConflictDetection] Failed to extract GPU information:', + error + ) + } + + // Default values + return { + available: ['CPU'], + primary: 'CPU', + memory_mb: undefined + } +} + +/** + * Unified version conflict check using Registry API version strings. + * Uses shared versionUtil functions for consistent version handling. + * @param type Type of version being checked + * @param currentVersion Current version string + * @param supportedVersion Supported version from Registry + * @returns Conflict detail if conflict exists, null otherwise + */ +function checkVersionConflict( + type: ConflictType, + currentVersion: string, + supportedVersion: string +): ConflictDetail | null { + // If current version is unknown, assume compatible (no conflict) + if (currentVersion === 'unknown') { + return null + } + + // If Registry doesn't specify version requirements, assume compatible + if (!supportedVersion || supportedVersion.trim() === '') { + return null + } + + try { + // Clean the current version using shared utility + const cleanCurrent = cleanVersion(currentVersion) + + // Check version compatibility using shared utility + const isCompatible = satisfiesVersion(cleanCurrent, supportedVersion) + + if (!isCompatible) { + // Generate user-friendly description using shared utility + const rangeDescription = describeVersionRange(supportedVersion) + const description = `${type} version incompatible: requires ${rangeDescription}` + + // Generate resolution steps based on version range type + let resolutionSteps: string[] = [] + + if (supportedVersion.startsWith('>=')) { + const minVersion = supportedVersion.substring(2).trim() + resolutionSteps = [ + `Update ${type} to version ${minVersion} or higher`, + 'Check release notes for compatibility changes' + ] + } else if (supportedVersion.includes(' - ')) { + resolutionSteps = [ + 'Verify your version is within the supported range', + `Supported range: ${supportedVersion}` + ] + } else if (supportedVersion.includes('||')) { + resolutionSteps = [ + 'Check which version ranges are supported', + `Supported: ${supportedVersion}` + ] + } else if ( + supportedVersion.startsWith('^') || + supportedVersion.startsWith('~') + ) { + resolutionSteps = [ + `Compatible versions: ${supportedVersion}`, + 'Consider updating or downgrading as needed' + ] + } else { + resolutionSteps = [ + `Required version: ${supportedVersion}`, + 'Check if your version is compatible' + ] + } + + return { + type, + severity: 'warning', + description, + current_value: currentVersion, + required_value: supportedVersion, + resolution_steps: resolutionSteps + } + } + + return null + } catch (error) { + console.warn( + `[ConflictDetection] Failed to parse version requirement: ${supportedVersion}`, + error + ) + return { + type, + severity: 'info', + description: `Unable to parse version requirement: ${supportedVersion}`, + current_value: currentVersion, + required_value: supportedVersion, + resolution_steps: [ + 'Check version format in Registry', + 'Manually verify compatibility' + ] + } + } +} + +/** + * Checks for OS compatibility conflicts. + */ +function checkOSConflict( + supportedOS: SupportedOS[], + currentOS: SupportedOS +): ConflictDetail | null { + if (supportedOS.includes('any') || supportedOS.includes(currentOS)) { + return null + } + + return { + type: 'os', + severity: 'error', + description: `Unsupported operating system`, + current_value: currentOS, + required_value: supportedOS.join(', '), + resolution_steps: ['Switch to supported OS', 'Find alternative package'] + } +} + +/** + * Checks for accelerator compatibility conflicts. + */ +function checkAcceleratorConflict( + supportedAccelerators: SupportedAccelerator[], + availableAccelerators: SupportedAccelerator[] +): ConflictDetail | null { + if ( + supportedAccelerators.includes('any') || + supportedAccelerators.some((acc) => availableAccelerators.includes(acc)) + ) { + return null + } + + return { + type: 'accelerator', + severity: 'error', + description: `Required GPU/accelerator not available`, + current_value: availableAccelerators.join(', '), + required_value: supportedAccelerators.join(', '), + resolution_steps: ['Install GPU drivers', 'Install CUDA/ROCm'] + } +} + +/** + * Determines recommended action based on detected conflicts. + */ +function determineRecommendedAction( + conflicts: ConflictDetail[] +): RecommendedAction { + if (conflicts.length === 0) { + return { + action_type: 'ignore', + reason: 'No conflicts detected', + steps: [], + estimated_difficulty: 'easy' + } + } + + const hasError = conflicts.some((c) => c.severity === 'error') + + return { + action_type: hasError ? 'disable' : 'manual_review', + reason: hasError + ? 'Critical compatibility issues found' + : 'Warning items need review', + steps: conflicts.flatMap((c) => c.resolution_steps || []), + estimated_difficulty: hasError ? 'hard' : 'medium' + } +} + +/** + * Generates summary of conflict detection results. + */ +function generateSummary( + results: ConflictDetectionResult[], + durationMs: number +): ConflictDetectionSummary { + const conflictsByType: Record = { + comfyui_version: 0, + frontend_version: 0, + python_version: 0, + os: 0, + accelerator: 0, + banned: 0, + security_pending: 0 + } + + const conflictsByTypeDetails: Record = { + comfyui_version: [], + frontend_version: [], + python_version: [], + os: [], + accelerator: [], + banned: [], + security_pending: [] + } + + let bannedCount = 0 + let securityPendingCount = 0 + + results.forEach((result) => { + result.conflicts.forEach((conflict) => { + conflictsByType[conflict.type]++ + + if (!conflictsByTypeDetails[conflict.type].includes(result.package_id)) { + conflictsByTypeDetails[conflict.type].push(result.package_id) + } + + if (conflict.type === 'banned') bannedCount++ + if (conflict.type === 'security_pending') securityPendingCount++ + }) + }) + + return { + total_packages: results.length, + compatible_packages: results.filter((r) => r.is_compatible).length, + conflicted_packages: results.filter((r) => r.has_conflict).length, + banned_packages: bannedCount, + security_pending_packages: securityPendingCount, + conflicts_by_type_details: conflictsByTypeDetails, + last_check_timestamp: new Date().toISOString(), + check_duration_ms: durationMs + } +} + +/** + * Creates an empty summary for error cases. + */ +function getEmptySummary(): ConflictDetectionSummary { + return { + total_packages: 0, + compatible_packages: 0, + conflicted_packages: 0, + banned_packages: 0, + security_pending_packages: 0, + conflicts_by_type_details: { + comfyui_version: [], + frontend_version: [], + python_version: [], + os: [], + accelerator: [], + banned: [], + security_pending: [] + }, + last_check_timestamp: new Date().toISOString(), + check_duration_ms: 0 + } +} diff --git a/src/stores/nodeCompatibilityStore.ts b/src/stores/nodeCompatibilityStore.ts new file mode 100644 index 000000000..3aff44e39 --- /dev/null +++ b/src/stores/nodeCompatibilityStore.ts @@ -0,0 +1,239 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import type { + ConflictType, + SystemEnvironment +} from '@/types/conflictDetectionTypes' + +interface IncompatibleNodeInfo { + nodeId: string + nodeName: string + disableReason: ConflictType + conflictDetails: string + detectedAt: string +} + +/** + * Store for managing node compatibility checking functionality. + * Follows error-resilient patterns from useConflictDetection composable. + */ +export const useNodeCompatibilityStore = defineStore( + 'nodeCompatibility', + () => { + // Core state + const isChecking = ref(false) + const lastCheckTime = ref(null) + const checkError = ref(null) + const systemEnvironment = ref(null) + + // Node tracking maps + const incompatibleNodes = ref>(new Map()) + const failedImportNodes = ref>(new Set()) + const bannedNodes = ref>(new Set()) + const securityPendingNodes = ref>(new Set()) + + // User interaction state + const hasShownNotificationModal = ref(false) + const pendingNotificationNodes = ref([]) + + // Computed properties + const hasIncompatibleNodes = computed( + () => incompatibleNodes.value.size > 0 + ) + const totalIncompatibleCount = computed( + () => + incompatibleNodes.value.size + + failedImportNodes.value.size + + bannedNodes.value.size + ) + + const incompatibleNodesList = computed(() => + Array.from(incompatibleNodes.value.values()) + ) + + const shouldShowNotification = computed(() => { + // Show notification if there are incompatible nodes and we haven't shown notification yet + return hasIncompatibleNodes.value && !hasShownNotificationModal.value + }) + + /** + * Checks if a node has compatibility issues. + */ + function hasNodeCompatibilityIssues(nodeId: string): boolean { + return ( + incompatibleNodes.value.has(nodeId) || + failedImportNodes.value.has(nodeId) || + bannedNodes.value.has(nodeId) + ) + } + + /** + * Gets the compatibility info for a node. + */ + function getNodeCompatibilityInfo( + nodeId: string + ): IncompatibleNodeInfo | null { + return incompatibleNodes.value.get(nodeId) || null + } + + /** + * Adds a node to the incompatible list. + */ + function addIncompatibleNode( + nodeId: string, + nodeName: string, + reason: ConflictType, + details: string + ): void { + const info: IncompatibleNodeInfo = { + nodeId, + nodeName, + disableReason: reason, + conflictDetails: details, + detectedAt: new Date().toISOString() + } + + incompatibleNodes.value.set(nodeId, info) + + // Add to pending list (for notification purposes) + if (!hasShownNotificationModal.value) { + pendingNotificationNodes.value.push(info) + } + } + + /** + * Removes a node from the incompatible list. + */ + function removeIncompatibleNode(nodeId: string): void { + incompatibleNodes.value.delete(nodeId) + failedImportNodes.value.delete(nodeId) + bannedNodes.value.delete(nodeId) + securityPendingNodes.value.delete(nodeId) + + // Remove from pending list + pendingNotificationNodes.value = pendingNotificationNodes.value.filter( + (node) => node.nodeId !== nodeId + ) + } + + /** + * Clears all compatibility check results. + */ + function clearResults(): void { + incompatibleNodes.value.clear() + failedImportNodes.value.clear() + bannedNodes.value.clear() + securityPendingNodes.value.clear() + pendingNotificationNodes.value = [] + checkError.value = null + } + + /** + * Marks that the notification modal has been shown. + */ + function markNotificationModalShown(): void { + hasShownNotificationModal.value = true + pendingNotificationNodes.value = [] + } + + /** + * Resets the notification modal state (for testing or re-initialization). + */ + function resetNotificationModalState(): void { + hasShownNotificationModal.value = false + pendingNotificationNodes.value = Array.from( + incompatibleNodes.value.values() + ) + } + + /** + * Updates the system environment information. + */ + function setSystemEnvironment(env: SystemEnvironment): void { + systemEnvironment.value = env + } + + /** + * Sets the checking state. + */ + function setCheckingState(checking: boolean): void { + isChecking.value = checking + if (checking) { + checkError.value = null + } + } + + /** + * Records a successful check completion. + */ + function recordCheckCompletion(): void { + lastCheckTime.value = new Date().toISOString() + isChecking.value = false + } + + /** + * Records a check error. + */ + function recordCheckError(error: string): void { + checkError.value = error + isChecking.value = false + } + + /** + * Gets a summary of the current compatibility state. + */ + function getCompatibilitySummary() { + return { + totalChecked: lastCheckTime.value ? 'completed' : 'pending', + incompatibleCount: incompatibleNodes.value.size, + failedImportCount: failedImportNodes.value.size, + bannedCount: bannedNodes.value.size, + securityPendingCount: securityPendingNodes.value.size, + totalIssues: totalIncompatibleCount.value, + lastCheckTime: lastCheckTime.value, + hasError: !!checkError.value + } + } + + return { + // State + isChecking: computed(() => isChecking.value), + lastCheckTime: computed(() => lastCheckTime.value), + checkError: computed(() => checkError.value), + systemEnvironment: computed(() => systemEnvironment.value), + + // Node tracking + incompatibleNodes: computed(() => incompatibleNodes.value), + incompatibleNodesList, + failedImportNodes: computed(() => failedImportNodes.value), + bannedNodes: computed(() => bannedNodes.value), + securityPendingNodes: computed(() => securityPendingNodes.value), + + // User interaction + hasShownNotificationModal: computed( + () => hasShownNotificationModal.value + ), + pendingNotificationNodes: computed(() => pendingNotificationNodes.value), + shouldShowNotification, + + // Computed + hasIncompatibleNodes, + totalIncompatibleCount, + + // Methods + hasNodeCompatibilityIssues, + getNodeCompatibilityInfo, + addIncompatibleNode, + removeIncompatibleNode, + clearResults, + markNotificationModalShown, + resetNotificationModalState, + setSystemEnvironment, + setCheckingState, + recordCheckCompletion, + recordCheckError, + getCompatibilitySummary + } + } +) diff --git a/src/types/conflictDetectionTypes.ts b/src/types/conflictDetectionTypes.ts new file mode 100644 index 000000000..ec72c9090 --- /dev/null +++ b/src/types/conflictDetectionTypes.ts @@ -0,0 +1,264 @@ +/** + * Type definitions for the conflict detection system. + * These types are used to detect compatibility issues between Node Packs and the system environment. + */ + +/** + * Conflict types that can be detected in the system + * @enum {string} + */ +export type ConflictType = + | 'comfyui_version' // ComfyUI version mismatch + | 'frontend_version' // Frontend version mismatch + | 'python_version' // Python version mismatch + | 'os' // Operating system incompatibility + | 'accelerator' // GPU/accelerator incompatibility + | 'banned' // Banned package + | 'security_pending' // Security verification pending + +/** + * Security scan status for packages + * @enum {string} + */ +export type SecurityScanStatus = 'pending' | 'passed' | 'failed' | 'unknown' + +/** + * Supported operating systems (as per Registry Admin guide) + * @enum {string} + */ +export type SupportedOS = 'Windows' | 'macOS' | 'Linux' | 'any' + +/** + * Supported accelerators for GPU computation (as per Registry Admin guide) + * @enum {string} + */ +export type SupportedAccelerator = 'CUDA' | 'ROCm' | 'Metal' | 'CPU' | 'any' + +/** + * Version comparison operators + * @enum {string} + */ +export type VersionOperator = '>=' | '>' | '<=' | '<' | '==' | '!=' + +/** + * Version requirement specification + */ +export interface VersionRequirement { + /** @description Comparison operator for version checking */ + operator: VersionOperator + /** @description Target version string */ + version: string +} + +/** + * Node Pack requirements from Registry API + */ +export interface NodePackRequirements { + /** @description Unique package identifier */ + package_id: string + /** @description Human-readable package name */ + package_name: string + /** @description Currently installed version */ + installed_version: string + /** @description Whether the package is enabled locally */ + is_enabled: boolean + + /** @description Supported ComfyUI version from Registry */ + supported_comfyui_version?: string + /** @description Supported frontend version from Registry */ + supported_comfyui_frontend_version?: string + /** @description List of supported operating systems from Registry */ + supported_os?: SupportedOS[] + /** @description List of supported accelerators from Registry */ + supported_accelerators?: SupportedAccelerator[] + /** @description Package dependencies from Registry */ + dependencies?: string[] + + /** @description Node status from Registry (Active/Banned/Deleted) */ + registry_status?: + | 'NodeStatusActive' + | 'NodeStatusBanned' + | 'NodeStatusDeleted' + /** @description Node version status from Registry */ + version_status?: + | 'NodeVersionStatusActive' + | 'NodeVersionStatusBanned' + | 'NodeVersionStatusDeleted' + | 'NodeVersionStatusPending' + | 'NodeVersionStatusFlagged' + /** @description Whether package is banned (derived from status) */ + is_banned: boolean + /** @description Reason for ban if applicable */ + ban_reason?: string + + // Metadata + /** @description Registry data fetch timestamp */ + registry_fetch_time: string + /** @description Whether Registry data was successfully fetched */ + has_registry_data: boolean +} + +/** + * Current system environment information + */ +export interface SystemEnvironment { + // Version information + /** @description Current ComfyUI version */ + comfyui_version: string + /** @description Current frontend version */ + frontend_version: string + /** @description Current Python version */ + python_version: string + + // Platform information + /** @description Operating system type */ + os: SupportedOS + /** @description Detailed platform information (e.g., 'Darwin 24.5.0', 'Windows 10') */ + platform_details: string + /** @description System architecture (e.g., 'x64', 'arm64') */ + architecture: string + + // GPU/accelerator information + /** @description List of available accelerators */ + available_accelerators: SupportedAccelerator[] + /** @description Primary accelerator in use */ + primary_accelerator: SupportedAccelerator + /** @description GPU memory in megabytes, if available */ + gpu_memory_mb?: number + + // Runtime information + /** @description Node.js environment mode */ + node_env: 'development' | 'production' + /** @description Browser user agent string */ + user_agent: string +} + +/** + * Individual conflict detection result for a package + */ +export interface ConflictDetectionResult { + /** @description Package identifier */ + package_id: string + /** @description Package name */ + package_name: string + /** @description Whether any conflicts were detected */ + has_conflict: boolean + /** @description List of detected conflicts */ + conflicts: ConflictDetail[] + /** @description Overall compatibility status */ + is_compatible: boolean + /** @description Whether conflicts can be automatically resolved */ + can_auto_resolve: boolean + /** @description Recommended action to resolve conflicts */ + recommended_action: RecommendedAction +} + +/** + * Detailed information about a specific conflict + */ +export interface ConflictDetail { + /** @description Type of conflict detected */ + type: ConflictType + /** @description Severity level of the conflict */ + severity: 'error' | 'warning' | 'info' + /** @description Human-readable description of the conflict */ + description: string + /** @description Current system value */ + current_value: string + /** @description Required value for compatibility */ + required_value: string + /** @description Optional steps to resolve the conflict */ + resolution_steps?: string[] +} + +/** + * Recommended action to resolve conflicts + */ +export interface RecommendedAction { + /** @description Type of action to take */ + action_type: 'disable' | 'update' | 'ignore' | 'manual_review' + /** @description Reason for the recommended action */ + reason: string + /** @description Step-by-step instructions */ + steps: string[] + /** @description Estimated difficulty of implementing the action */ + estimated_difficulty: 'easy' | 'medium' | 'hard' +} + +/** + * Overall conflict detection summary + */ +export interface ConflictDetectionSummary { + /** @description Total number of packages checked */ + total_packages: number + /** @description Number of compatible packages */ + compatible_packages: number + /** @description Number of packages with conflicts */ + conflicted_packages: number + /** @description Number of banned packages */ + banned_packages: number + /** @description Number of packages pending security verification */ + security_pending_packages: number + /** @description Node IDs grouped by conflict type */ + conflicts_by_type_details: Record + /** @description Timestamp of the last conflict check */ + last_check_timestamp: string + /** @description Duration of the conflict check in milliseconds */ + check_duration_ms: number +} + +/** + * API request/response interfaces + */ + +/** + * Request payload for conflict detection API + */ +export interface ConflictDetectionRequest { + /** @description Current system environment information */ + system_environment: SystemEnvironment + /** @description Optional list of specific package IDs to check */ + package_ids?: string[] + /** @description Whether to include banned packages in the check */ + include_banned?: boolean + /** @description Whether to include security-pending packages in the check */ + include_security_pending?: boolean +} + +/** + * Response payload from conflict detection API + */ +export interface ConflictDetectionResponse { + /** @description Whether the API request was successful */ + success: boolean + /** @description Error message if the request failed */ + error_message?: string + + /** @description Summary of the conflict detection results */ + summary: ConflictDetectionSummary + /** @description Detailed results for each package */ + results: ConflictDetectionResult[] + + /** @description System environment information detected by the server (for comparison) */ + detected_system_environment?: Partial +} + +/** + * Real-time conflict detection event + */ +export interface ConflictDetectionEvent { + /** @description Type of event */ + event_type: + | 'conflict_detected' + | 'conflict_resolved' + | 'scan_started' + | 'scan_completed' + /** @description Event timestamp */ + timestamp: string + /** @description Package ID associated with the event, if applicable */ + package_id?: string + /** @description Type of conflict, if applicable */ + conflict_type?: ConflictType + /** @description Additional event details */ + details?: string +} diff --git a/src/utils/versionUtil.ts b/src/utils/versionUtil.ts new file mode 100644 index 000000000..268393ba7 --- /dev/null +++ b/src/utils/versionUtil.ts @@ -0,0 +1,80 @@ +import * as semver from 'semver' + +/** + * Cleans a version string by removing common prefixes and normalizing format + * @param version Raw version string (e.g., "v1.2.3", "1.2.3-alpha") + * @returns Cleaned version string or original if cleaning fails + */ +export function cleanVersion(version: string): string { + return semver.clean(version) || version +} + +/** + * Checks if a version satisfies a version range + * @param version Current version + * @param range Version range (e.g., ">=1.0.0", "^1.2.0", "1.0.0 - 2.0.0") + * @returns true if version satisfies the range + */ +export function satisfiesVersion(version: string, range: string): boolean { + try { + const cleanedVersion = cleanVersion(version) + return semver.satisfies(cleanedVersion, range) + } catch { + return false + } +} + +/** + * Compares two versions and returns the difference type + * @param version1 First version + * @param version2 Second version + * @returns Difference type or null if comparison fails + */ +export function getVersionDifference( + version1: string, + version2: string +): semver.ReleaseType | null { + try { + const clean1 = cleanVersion(version1) + const clean2 = cleanVersion(version2) + return semver.diff(clean1, clean2) + } catch { + return null + } +} + +/** + * Checks if a version is valid according to semver + * @param version Version string to validate + * @returns true if version is valid + */ +export function isValidVersion(version: string): boolean { + return semver.valid(version) !== null +} + +/** + * Gets a human-readable description of a version range + * @param range Version range string + * @returns Description of what the range means + */ +export function describeVersionRange(range: string): string { + if (range.startsWith('>=')) { + return `version ${range.substring(2)} or higher` + } else if (range.startsWith('>')) { + return `version higher than ${range.substring(1)}` + } else if (range.startsWith('<=')) { + return `version ${range.substring(2)} or lower` + } else if (range.startsWith('<')) { + return `version lower than ${range.substring(1)}` + } else if (range.startsWith('^')) { + return `compatible with version ${range.substring(1)}` + } else if (range.startsWith('~')) { + return `approximately version ${range.substring(1)}` + } else if (range.includes(' - ')) { + const [min, max] = range.split(' - ') + return `version between ${min} and ${max}` + } else if (range.includes('||')) { + return `one of multiple version ranges: ${range}` + } + return `version ${range}` +} \ No newline at end of file diff --git a/tests-ui/tests/composables/useConflictDetection.test.ts b/tests-ui/tests/composables/useConflictDetection.test.ts new file mode 100644 index 000000000..0b90c0f31 --- /dev/null +++ b/tests-ui/tests/composables/useConflictDetection.test.ts @@ -0,0 +1,864 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' + +import { useConflictDetection } from '@/composables/useConflictDetection' +import type { components } from '@/types/comfyRegistryTypes' +import type { components as ManagerComponents } from '@/types/generatedManagerTypes' + +// Mock dependencies +vi.mock('@/scripts/api', () => ({ + api: { + fetchApi: vi.fn() + } +})) + +vi.mock('@/services/comfyManagerService', () => ({ + useComfyManagerService: vi.fn() +})) + +vi.mock('@/services/comfyRegistryService', () => ({ + useComfyRegistryService: vi.fn() +})) + +vi.mock('@/stores/systemStatsStore', () => ({ + useSystemStatsStore: vi.fn() +})) + +vi.mock('@/config', () => ({ + default: { + app_version: '1.24.0-1' + } +})) + +describe('useConflictDetection with Registry Store', () => { + const mockComfyManagerService = { + listInstalledPacks: vi.fn() + } + + const mockRegistryService = { + getPackByVersion: vi.fn() + } + + const mockSystemStatsStore = { + fetchSystemStats: vi.fn(), + systemStats: { + system: { + comfyui_version: '0.3.41', + python_version: '3.12.11', + os: 'Darwin' + }, + devices: [ + { + name: 'Apple M1 Pro', + type: 'mps', + vram_total: 17179869184 + } + ] + } as any + } + + beforeEach(async () => { + vi.clearAllMocks() + + // Reset mock system stats to default state + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '0.3.41', + python_version: '3.12.11', + os: 'Darwin' + }, + devices: [ + { + name: 'Apple M1 Pro', + type: 'mps', + vram_total: 17179869184 + } + ] + } as any + + // Reset mock functions + mockSystemStatsStore.fetchSystemStats.mockResolvedValue(undefined) + mockComfyManagerService.listInstalledPacks.mockReset() + mockRegistryService.getPackByVersion.mockReset() + + // Mock useComfyManagerService + const { useComfyManagerService } = await import( + '@/services/comfyManagerService' + ) + vi.mocked(useComfyManagerService).mockReturnValue( + mockComfyManagerService as any + ) + + // Mock useComfyRegistryService + const { useComfyRegistryService } = await import( + '@/services/comfyRegistryService' + ) + vi.mocked(useComfyRegistryService).mockReturnValue( + mockRegistryService as any + ) + + // Mock useSystemStatsStore + const { useSystemStatsStore } = await import('@/stores/systemStatsStore') + vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore as any) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('system environment detection', () => { + it('should collect system environment information successfully', async () => { + const { detectSystemEnvironment } = useConflictDetection() + const environment = await detectSystemEnvironment() + + expect(environment.comfyui_version).toBe('0.3.41') + expect(environment.frontend_version).toBe('1.24.0-1') + expect(environment.python_version).toBe('3.12.11') + expect(environment.available_accelerators).toContain('Metal') + expect(environment.available_accelerators).toContain('CPU') + expect(environment.primary_accelerator).toBe('Metal') + }) + + it('should return fallback environment information when systemStatsStore fails', async () => { + // Mock systemStatsStore failure + mockSystemStatsStore.fetchSystemStats.mockRejectedValue( + new Error('Store failure') + ) + mockSystemStatsStore.systemStats = null + + const { detectSystemEnvironment } = useConflictDetection() + const environment = await detectSystemEnvironment() + + expect(environment.comfyui_version).toBe('unknown') + expect(environment.frontend_version).toBe('1.24.0-1') + expect(environment.python_version).toBe('unknown') + expect(environment.available_accelerators).toEqual(['CPU']) + }) + }) + + describe('package requirements detection with Registry Store', () => { + it('should fetch and combine local + Registry data successfully', async () => { + // Mock installed packages + const mockInstalledPacks: ManagerComponents['schemas']['InstalledPacksResponse'] = + { + 'ComfyUI-Manager': { + ver: 'cb0fa5829d5378e5dddb8e8515b30a3ff20e1471', + cnr_id: '', + aux_id: 'viva-jinyi/ComfyUI-Manager', + enabled: true + }, + 'ComfyUI-TestNode': { + ver: '1.0.0', + cnr_id: 'test-node', + aux_id: null, + enabled: false + } + } + + // Mock Registry data + const mockRegistryPacks: components['schemas']['Node'][] = [ + { + id: 'ComfyUI-Manager', + name: 'ComfyUI Manager', + supported_os: ['Windows', 'Linux', 'macOS'], + supported_accelerators: ['CUDA', 'Metal', 'CPU'], + supported_comfyui_version: '>=0.3.0', + status: 'NodeStatusActive' + } as components['schemas']['Node'], + { + id: 'ComfyUI-TestNode', + name: 'Test Node', + supported_os: ['Windows', 'Linux'], + supported_accelerators: ['CUDA'], + supported_comfyui_version: '>=0.2.0', + status: 'NodeStatusBanned' + } as components['schemas']['Node'] + ] + + mockComfyManagerService.listInstalledPacks.mockResolvedValue( + mockInstalledPacks + ) + + // Mock Registry Service individual calls + mockRegistryService.getPackByVersion.mockImplementation( + (packageName: string) => { + const packageData = mockRegistryPacks.find( + (p) => p.id === packageName + ) + if (packageData) { + return Promise.resolve({ + ...packageData, + supported_comfyui_version: packageData.supported_comfyui_version, + supported_os: packageData.supported_os, + supported_accelerators: packageData.supported_accelerators, + status: packageData.status + }) + } + return Promise.resolve(null) + } + ) + + const { performConflictDetection } = useConflictDetection() + const result = await performConflictDetection() + + expect(result.success).toBe(true) + expect(result.summary.total_packages).toBe(2) + expect(result.results).toHaveLength(2) + + // Verify individual calls were made + expect(mockRegistryService.getPackByVersion).toHaveBeenCalledWith( + 'ComfyUI-Manager', + 'cb0fa5829d5378e5dddb8e8515b30a3ff20e1471', + expect.anything() + ) + expect(mockRegistryService.getPackByVersion).toHaveBeenCalledWith( + 'ComfyUI-TestNode', + '1.0.0', + expect.anything() + ) + + // Check that Registry data was properly integrated + const managerNode = result.results.find( + (r) => r.package_id === 'ComfyUI-Manager' + ) + expect(managerNode?.is_compatible).toBe(true) // Should be compatible + + // Disabled + banned node should have conflicts + const testNode = result.results.find( + (r) => r.package_id === 'ComfyUI-TestNode' + ) + expect(testNode?.conflicts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'banned', + severity: 'error' + }) + ]) + ) + }) + + it('should handle Registry Store failures gracefully', async () => { + // Mock installed packages + const mockInstalledPacks: ManagerComponents['schemas']['InstalledPacksResponse'] = + { + 'Unknown-Package': { + ver: '1.0.0', + cnr_id: 'unknown', + aux_id: null, + enabled: true + } + } + + mockComfyManagerService.listInstalledPacks.mockResolvedValue( + mockInstalledPacks + ) + + // Mock Registry Service returning null (no packages found) + mockRegistryService.getPackByVersion.mockResolvedValue(null) + + const { performConflictDetection } = useConflictDetection() + const result = await performConflictDetection() + + expect(result.success).toBe(true) + expect(result.summary.total_packages).toBe(1) + expect(result.results).toHaveLength(1) + + // Should have warning about missing Registry data + const unknownPackage = result.results[0] + expect(unknownPackage.conflicts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'security_pending', + severity: 'warning', + description: expect.stringContaining('Registry data not available') + }) + ]) + ) + }) + + it('should return empty array when local package information cannot be retrieved', async () => { + mockComfyManagerService.listInstalledPacks.mockResolvedValue(null) + + const { performConflictDetection } = useConflictDetection() + const result = await performConflictDetection() + + expect(result.success).toBe(true) + expect(result.summary.total_packages).toBe(0) + expect(result.results).toHaveLength(0) + }) + }) + + describe('conflict detection logic with Registry Store', () => { + it('should detect no conflicts for fully compatible packages', async () => { + // Mock compatible package + const mockInstalledPacks: ManagerComponents['schemas']['InstalledPacksResponse'] = + { + CompatibleNode: { + ver: '1.0.0', + cnr_id: 'compatible-node', + aux_id: null, + enabled: true + } + } + + const mockCompatibleRegistryPacks: components['schemas']['Node'][] = [ + { + id: 'CompatibleNode', + name: 'Compatible Node', + supported_os: ['Windows', 'Linux', 'macOS'], + supported_accelerators: ['Metal', 'CUDA', 'CPU'], + supported_comfyui_version: '>=0.3.0', + status: 'NodeStatusActive' + } as components['schemas']['Node'] + ] + + mockComfyManagerService.listInstalledPacks.mockResolvedValue( + mockInstalledPacks + ) + // Mock Registry Service for compatible package + mockRegistryService.getPackByVersion.mockImplementation( + (packageName: string) => { + const packageData = mockCompatibleRegistryPacks.find( + (p) => p.id === packageName + ) + return Promise.resolve(packageData || null) + } + ) + + const { performConflictDetection } = useConflictDetection() + const result = await performConflictDetection() + + expect(result.success).toBe(true) + expect(result.summary.conflicted_packages).toBe(0) + expect(result.summary.compatible_packages).toBe(1) + expect(result.results[0].conflicts).toHaveLength(0) + }) + + it('should detect OS incompatibility conflicts', async () => { + // Mock OS-incompatible package + const mockInstalledPacks: ManagerComponents['schemas']['InstalledPacksResponse'] = + { + WindowsOnlyNode: { + ver: '1.0.0', + cnr_id: 'windows-only', + aux_id: null, + enabled: true + } + } + + const mockWindowsOnlyRegistryPacks: components['schemas']['Node'][] = [ + { + id: 'WindowsOnlyNode', + name: 'Windows Only Node', + supported_os: ['Windows'], + supported_accelerators: ['Metal', 'CUDA', 'CPU'], + supported_comfyui_version: '>=0.3.0', + status: 'NodeStatusActive' + } as components['schemas']['Node'] + ] + + mockComfyManagerService.listInstalledPacks.mockResolvedValue( + mockInstalledPacks + ) + mockRegistryService.getPackByVersion.mockImplementation( + (packageName: string) => { + const packageData = mockWindowsOnlyRegistryPacks.find( + (p: any) => p.id === packageName + ) + return Promise.resolve(packageData || null) + } + ) + + const { performConflictDetection } = useConflictDetection() + const result = await performConflictDetection() + + expect(result.success).toBe(true) + expect(result.summary.conflicted_packages).toBe(1) + + const windowsNode = result.results[0] + expect(windowsNode.conflicts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'os', + severity: 'error', + description: expect.stringContaining('Unsupported operating system') + }) + ]) + ) + }) + + it('should detect accelerator incompatibility conflicts', async () => { + // Mock CUDA-only package + const mockInstalledPacks: ManagerComponents['schemas']['InstalledPacksResponse'] = + { + CudaOnlyNode: { + ver: '1.0.0', + cnr_id: 'cuda-only', + aux_id: null, + enabled: true + } + } + + const mockCudaOnlyRegistryPacks: components['schemas']['Node'][] = [ + { + id: 'CudaOnlyNode', + name: 'CUDA Only Node', + supported_os: ['windows', 'linux', 'macos'], + supported_accelerators: ['CUDA'], + supported_comfyui_version: '>=0.3.0', + status: 'NodeStatusActive' + } as components['schemas']['Node'] + ] + + mockComfyManagerService.listInstalledPacks.mockResolvedValue( + mockInstalledPacks + ) + mockRegistryService.getPackByVersion.mockImplementation( + (packageName: string) => { + const packageData = mockCudaOnlyRegistryPacks.find( + (p: any) => p.id === packageName + ) + return Promise.resolve(packageData || null) + } + ) + + const { performConflictDetection } = useConflictDetection() + const result = await performConflictDetection() + + expect(result.success).toBe(true) + expect(result.summary.conflicted_packages).toBe(1) + + const cudaNode = result.results[0] + expect(cudaNode.conflicts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'accelerator', + severity: 'error', + description: expect.stringContaining( + 'Required GPU/accelerator not available' + ) + }) + ]) + ) + }) + + it('should treat Registry-banned packages as conflicts', async () => { + // Mock Registry-banned package + const mockInstalledPacks: ManagerComponents['schemas']['InstalledPacksResponse'] = + { + BannedNode: { + ver: '1.0.0', + cnr_id: 'banned-node', + aux_id: null, + enabled: true + } + } + + const mockBannedRegistryPacks: components['schemas']['NodeVersion'][] = [ + { + id: 'BannedNode', + supported_os: ['windows', 'linux', 'macos'], + supported_accelerators: ['Metal', 'CUDA', 'CPU'], + supported_comfyui_version: '>=0.3.0', + status: 'NodeVersionStatusBanned' + } as components['schemas']['NodeVersion'] + ] + + mockComfyManagerService.listInstalledPacks.mockResolvedValue( + mockInstalledPacks + ) + mockRegistryService.getPackByVersion.mockImplementation( + (packageName: string) => { + const packageData = mockBannedRegistryPacks.find( + (p: any) => p.id === packageName + ) + return Promise.resolve(packageData || null) + } + ) + + const { performConflictDetection } = useConflictDetection() + const result = await performConflictDetection() + + expect(result.success).toBe(true) + expect(result.summary.banned_packages).toBe(1) + + const bannedNode = result.results[0] + expect(bannedNode.conflicts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'banned', + severity: 'error', + description: expect.stringContaining('Package is banned') + }) + ]) + ) + expect(bannedNode.recommended_action.action_type).toBe('disable') + }) + + it('should treat locally disabled packages as banned', async () => { + // Mock locally disabled package + const mockInstalledPacks: ManagerComponents['schemas']['InstalledPacksResponse'] = + { + DisabledNode: { + ver: '1.0.0', + cnr_id: 'disabled-node', + aux_id: null, + enabled: false + } + } + + const mockActiveRegistryPacks: components['schemas']['Node'][] = [ + { + id: 'DisabledNode', + name: 'Disabled Node', + supported_os: ['windows', 'linux', 'macos'], + supported_accelerators: ['Metal', 'CUDA', 'CPU'], + supported_comfyui_version: '>=0.3.0', + status: 'NodeStatusActive' + } as components['schemas']['Node'] + ] + + mockComfyManagerService.listInstalledPacks.mockResolvedValue( + mockInstalledPacks + ) + mockRegistryService.getPackByVersion.mockImplementation( + (packageName: string) => { + const packageData = mockActiveRegistryPacks.find( + (p: any) => p.id === packageName + ) + return Promise.resolve(packageData || null) + } + ) + + const { performConflictDetection } = useConflictDetection() + const result = await performConflictDetection() + + expect(result.success).toBe(true) + expect(result.summary.banned_packages).toBe(1) + + const disabledNode = result.results[0] + expect(disabledNode.conflicts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'banned', + severity: 'error', + description: expect.stringContaining('Package is disabled locally') + }) + ]) + ) + expect(disabledNode.recommended_action.action_type).toBe('disable') + }) + }) + + describe('computed properties with Registry Store', () => { + it('should return true for hasConflicts when Registry conflicts exist', async () => { + // Mock package with OS incompatibility + const mockInstalledPacks: ManagerComponents['schemas']['InstalledPacksResponse'] = + { + ConflictedNode: { + ver: '1.0.0', + cnr_id: 'conflicted-node', + aux_id: null, + enabled: true + } + } + + const mockConflictedRegistryPacks: components['schemas']['Node'][] = [ + { + id: 'ConflictedNode', + name: 'Conflicted Node', + supported_os: ['Windows'], + supported_accelerators: ['Metal', 'CUDA', 'CPU'], + supported_comfyui_version: '>=0.3.0', + status: 'NodeStatusActive' + } as components['schemas']['Node'] + ] + + mockComfyManagerService.listInstalledPacks.mockResolvedValue( + mockInstalledPacks + ) + mockRegistryService.getPackByVersion.mockImplementation( + (packageName: string) => { + const packageData = mockConflictedRegistryPacks.find( + (p: any) => p.id === packageName + ) + return Promise.resolve(packageData || null) + } + ) + + const { hasConflicts, performConflictDetection } = useConflictDetection() + + // Initial value should be false + expect(hasConflicts.value).toBe(false) + + // Execute conflict detection + await performConflictDetection() + await nextTick() + + // Should be true when conflicts are detected + expect(hasConflicts.value).toBe(true) + }) + + it('should return only error-level conflicts for criticalConflicts', async () => { + // Mock package with error-level conflict + const mockInstalledPacks: ManagerComponents['schemas']['InstalledPacksResponse'] = + { + ErrorNode: { + ver: '1.0.0', + cnr_id: 'error-node', + aux_id: null, + enabled: true + } + } + + const mockErrorRegistryPacks: components['schemas']['Node'][] = [ + { + id: 'ErrorNode', + name: 'Error Node', + supported_os: ['Windows'], + supported_accelerators: ['CUDA'], + supported_comfyui_version: '>=0.3.0', + status: 'NodeStatusActive' + } as components['schemas']['Node'] + ] + + mockComfyManagerService.listInstalledPacks.mockResolvedValue( + mockInstalledPacks + ) + mockRegistryService.getPackByVersion.mockImplementation( + (packageName: string) => { + const packageData = mockErrorRegistryPacks.find( + (p: any) => p.id === packageName + ) + return Promise.resolve(packageData || null) + } + ) + + const { criticalConflicts, performConflictDetection } = + useConflictDetection() + + await performConflictDetection() + await nextTick() + + expect(criticalConflicts.value.length).toBeGreaterThan(0) + expect( + criticalConflicts.value.every( + (conflict) => conflict.severity === 'error' + ) + ).toBe(true) + }) + + it('should return only banned packages for bannedPackages', async () => { + // Mock one banned and one normal package + const mockInstalledPacks: ManagerComponents['schemas']['InstalledPacksResponse'] = + { + BannedNode: { + ver: '1.0.0', + cnr_id: 'banned-node', + aux_id: null, + enabled: false + }, + NormalNode: { + ver: '1.0.0', + cnr_id: 'normal-node', + aux_id: null, + enabled: true + } + } + + const mockRegistryPacks: components['schemas']['Node'][] = [ + { + id: 'BannedNode', + name: 'Banned Node', + supported_os: ['windows', 'linux', 'macos'], + supported_accelerators: ['Metal', 'CUDA', 'CPU'], + supported_comfyui_version: '>=0.3.0', + status: 'NodeStatusActive' + } as components['schemas']['Node'], + { + id: 'NormalNode', + name: 'Normal Node', + supported_os: ['windows', 'linux', 'macos'], + supported_accelerators: ['Metal', 'CUDA', 'CPU'], + supported_comfyui_version: '>=0.3.0', + status: 'NodeStatusActive' + } as components['schemas']['Node'] + ] + + mockComfyManagerService.listInstalledPacks.mockResolvedValue( + mockInstalledPacks + ) + mockRegistryService.getPackByVersion.mockImplementation( + (packageName: string) => { + const packageData = mockRegistryPacks.find( + (p: any) => p.id === packageName + ) + return Promise.resolve(packageData || null) + } + ) + + const { bannedPackages, performConflictDetection } = + useConflictDetection() + + await performConflictDetection() + await nextTick() + + expect(bannedPackages.value).toHaveLength(1) + expect(bannedPackages.value[0].package_id).toBe('BannedNode') + }) + }) + + describe('error resilience with Registry Store', () => { + it('should continue execution even when system environment detection fails', async () => { + // Mock system stats store failure + mockSystemStatsStore.fetchSystemStats.mockRejectedValue( + new Error('Store error') + ) + mockSystemStatsStore.systemStats = null + mockComfyManagerService.listInstalledPacks.mockResolvedValue({}) + mockRegistryService.getPackByVersion.mockImplementation( + (packageName: string) => { + const packageData = [].find((p: any) => p.id === packageName) + return Promise.resolve(packageData || null) + } + ) + + const { performConflictDetection } = useConflictDetection() + const result = await performConflictDetection() + + expect(result.success).toBe(true) + expect(result.detected_system_environment?.comfyui_version).toBe( + 'unknown' + ) + }) + + it('should continue system operation even when local package information fails', async () => { + // Mock local package service failure + mockComfyManagerService.listInstalledPacks.mockRejectedValue( + new Error('Service error') + ) + + const { performConflictDetection } = useConflictDetection() + const result = await performConflictDetection() + + expect(result.success).toBe(true) + expect(result.summary.total_packages).toBe(0) + }) + + it('should handle Registry Store partial data gracefully', async () => { + // Mock successful local data but partial Registry data + const mockInstalledPacks: ManagerComponents['schemas']['InstalledPacksResponse'] = + { + 'Package-A': { + ver: '1.0.0', + cnr_id: 'a', + aux_id: null, + enabled: true + }, + 'Package-B': { + ver: '2.0.0', + cnr_id: 'b', + aux_id: null, + enabled: true + } + } + + mockComfyManagerService.listInstalledPacks.mockResolvedValue( + mockInstalledPacks + ) + + // Only first package found in Registry / Registry에서 첫 번째 패키지만 찾음 + const mockPartialRegistryPacks: components['schemas']['Node'][] = [ + { + id: 'Package-A', + name: 'Package A', + supported_os: ['windows', 'linux', 'macos'], + supported_accelerators: ['Metal', 'CUDA', 'CPU'], + status: 'NodeStatusActive' + } as components['schemas']['Node'] + // Package-B is missing from Registry results + ] + + mockRegistryService.getPackByVersion.mockImplementation( + (packageName: string) => { + const packageData = mockPartialRegistryPacks.find( + (p: any) => p.id === packageName + ) + return Promise.resolve(packageData || null) + } + ) + + const { performConflictDetection } = useConflictDetection() + const result = await performConflictDetection() + + expect(result.success).toBe(true) + expect(result.summary.total_packages).toBe(2) + + // Package A should have Registry data + const packageA = result.results.find((r) => r.package_id === 'Package-A') + expect(packageA?.conflicts).toHaveLength(0) // No conflicts + + // Package B should have warning about missing Registry data + const packageB = result.results.find((r) => r.package_id === 'Package-B') + expect(packageB?.conflicts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'security_pending', + severity: 'warning', + description: expect.stringContaining('Registry data not available') + }) + ]) + ) + }) + + it('should handle complete system failure gracefully', async () => { + // Mock all stores/services failing + mockSystemStatsStore.fetchSystemStats.mockRejectedValue( + new Error('Critical error') + ) + mockSystemStatsStore.systemStats = null + mockComfyManagerService.listInstalledPacks.mockRejectedValue( + new Error('Critical error') + ) + mockRegistryService.getPackByVersion.mockRejectedValue( + new Error('Critical error') + ) + + const { performConflictDetection } = useConflictDetection() + const result = await performConflictDetection() + + expect(result.success).toBe(true) // Error resilience maintains success + expect(result.summary.total_packages).toBe(0) + }) + }) + + describe('initialization', () => { + it('should execute initializeConflictDetection without errors', async () => { + mockComfyManagerService.listInstalledPacks.mockResolvedValue({}) + + const { initializeConflictDetection } = useConflictDetection() + + expect(() => { + void initializeConflictDetection() + }).not.toThrow() + }) + + it('should set initial state values correctly', () => { + const { + isDetecting, + lastDetectionTime, + detectionError, + systemEnvironment, + detectionResults, + detectionSummary + } = useConflictDetection() + + expect(isDetecting.value).toBe(false) + expect(lastDetectionTime.value).toBeNull() + expect(detectionError.value).toBeNull() + expect(systemEnvironment.value).toBeNull() + expect(detectionResults.value).toEqual([]) + expect(detectionSummary.value).toBeNull() + }) + }) +}) diff --git a/tests-ui/tests/stores/nodeCompatibilityStore.test.ts b/tests-ui/tests/stores/nodeCompatibilityStore.test.ts new file mode 100644 index 000000000..70e54a319 --- /dev/null +++ b/tests-ui/tests/stores/nodeCompatibilityStore.test.ts @@ -0,0 +1,129 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' + +import { useNodeCompatibilityStore } from '@/stores/nodeCompatibilityStore' + +describe('useNodeCompatibilityStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('should initialize with empty state', () => { + const store = useNodeCompatibilityStore() + + expect(store.isChecking).toBe(false) + expect(store.lastCheckTime).toBeNull() + expect(store.checkError).toBeNull() + expect(store.hasIncompatibleNodes).toBe(false) + expect(store.totalIncompatibleCount).toBe(0) + expect(store.shouldShowNotification).toBe(false) + }) + + it('should add incompatible nodes correctly', () => { + const store = useNodeCompatibilityStore() + + store.addIncompatibleNode( + 'test-node', + 'Test Node', + 'banned', + 'Node is banned for testing' + ) + + expect(store.hasIncompatibleNodes).toBe(true) + expect(store.totalIncompatibleCount).toBe(1) + expect(store.hasNodeCompatibilityIssues('test-node')).toBe(true) + + const compatibilityInfo = store.getNodeCompatibilityInfo('test-node') + expect(compatibilityInfo).toBeDefined() + expect(compatibilityInfo?.disableReason).toBe('banned') + }) + + it('should remove incompatible nodes correctly', () => { + const store = useNodeCompatibilityStore() + + store.addIncompatibleNode( + 'test-node', + 'Test Node', + 'banned', + 'Node is banned for testing' + ) + + expect(store.hasIncompatibleNodes).toBe(true) + + store.removeIncompatibleNode('test-node') + + expect(store.hasIncompatibleNodes).toBe(false) + expect(store.hasNodeCompatibilityIssues('test-node')).toBe(false) + }) + + it('should handle notification modal state correctly', () => { + const store = useNodeCompatibilityStore() + + // Add an incompatible node + store.addIncompatibleNode( + 'test-node', + 'Test Node', + 'banned', + 'Node is banned for testing' + ) + + expect(store.shouldShowNotification).toBe(true) + expect(store.pendingNotificationNodes).toHaveLength(1) + + store.markNotificationModalShown() + + expect(store.shouldShowNotification).toBe(false) + expect(store.pendingNotificationNodes).toHaveLength(0) + }) + + it('should clear all results correctly', () => { + const store = useNodeCompatibilityStore() + + store.addIncompatibleNode( + 'test-node', + 'Test Node', + 'banned', + 'Node is banned for testing' + ) + store.recordCheckError('Test error') + + expect(store.hasIncompatibleNodes).toBe(true) + expect(store.checkError).toBe('Test error') + + store.clearResults() + + expect(store.hasIncompatibleNodes).toBe(false) + expect(store.checkError).toBeNull() + }) + + it('should track checking state correctly', () => { + const store = useNodeCompatibilityStore() + + expect(store.isChecking).toBe(false) + + store.setCheckingState(true) + expect(store.isChecking).toBe(true) + + store.recordCheckCompletion() + expect(store.isChecking).toBe(false) + expect(store.lastCheckTime).toBeDefined() + }) + + it('should provide compatibility summary', () => { + const store = useNodeCompatibilityStore() + + store.addIncompatibleNode( + 'banned-node', + 'Banned Node', + 'banned', + 'Node is banned' + ) + + const summary = store.getCompatibilitySummary() + + expect(summary.incompatibleCount).toBe(1) + expect(summary.bannedCount).toBe(0) // bannedNodes is separate from incompatibleNodes + expect(summary.totalIssues).toBe(1) + expect(summary.hasError).toBe(false) + }) +})