diff --git a/browser_tests/tests/versionMismatchWarnings.spec.ts b/browser_tests/tests/versionMismatchWarnings.spec.ts new file mode 100644 index 000000000..d85f18723 --- /dev/null +++ b/browser_tests/tests/versionMismatchWarnings.spec.ts @@ -0,0 +1,117 @@ +import { expect } from '@playwright/test' + +import { SystemStats } from '../../src/schemas/apiSchema' +import { comfyPageFixture as test } from '../fixtures/ComfyPage' + +test.describe('Version Mismatch Warnings', () => { + const ALWAYS_AHEAD_OF_INSTALLED_VERSION = '100.100.100' + const ALWAYS_BEHIND_INSTALLED_VERSION = '0.0.0' + + const createMockSystemStatsRes = ( + requiredFrontendVersion: string + ): SystemStats => { + return { + system: { + os: 'posix', + ram_total: 67235385344, + ram_free: 13464207360, + comfyui_version: '0.3.46', + required_frontend_version: requiredFrontendVersion, + python_version: '3.12.3 (main, Jun 18 2025, 17:59:45) [GCC 13.3.0]', + pytorch_version: '2.6.0+cu124', + embedded_python: false, + argv: ['main.py'] + }, + devices: [ + { + name: 'cuda:0 NVIDIA GeForce RTX 4070 : cudaMallocAsync', + type: 'cuda', + index: 0, + vram_total: 12557156352, + vram_free: 2439249920, + torch_vram_total: 0, + torch_vram_free: 0 + } + ] + } + } + + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + }) + + test('should show version mismatch warnings when installed version lower than required', async ({ + comfyPage + }) => { + // Mock system_stats route to indicate that the installed version is always ahead of the required version + await comfyPage.page.route('**/system_stats**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify( + createMockSystemStatsRes(ALWAYS_AHEAD_OF_INSTALLED_VERSION) + ) + }) + }) + await comfyPage.setup() + + // Expect a warning toast to be shown + await expect( + comfyPage.page.getByText('Version Compatibility Warning') + ).toBeVisible() + }) + + test('should not show version mismatch warnings when installed version is ahead of required', async ({ + comfyPage + }) => { + // Mock system_stats route to indicate that the installed version is always ahead of the required version + await comfyPage.page.route('**/system_stats**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify( + createMockSystemStatsRes(ALWAYS_BEHIND_INSTALLED_VERSION) + ) + }) + }) + await comfyPage.setup() + + // Expect no warning toast to be shown + await expect( + comfyPage.page.getByText('Version Compatibility Warning') + ).not.toBeVisible() + }) + + test('should persist dismissed state across sessions', async ({ + comfyPage + }) => { + // Mock system_stats route to indicate that the installed version is always ahead of the required version + await comfyPage.page.route('**/system_stats**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify( + createMockSystemStatsRes(ALWAYS_AHEAD_OF_INSTALLED_VERSION) + ) + }) + }) + await comfyPage.setup() + + // Locate the warning toast and dismiss it + const warningToast = comfyPage.page + .locator('div') + .filter({ hasText: 'Version Compatibility' }) + .nth(3) + await warningToast.waitFor({ state: 'visible' }) + const dismissButton = warningToast.getByRole('button', { name: 'Close' }) + await dismissButton.click() + + // Reload the page, keeping local storage + await comfyPage.setup({ clearStorage: false }) + + // The same warning from same versions should not be shown to the user again + await expect( + comfyPage.page.getByText('Version Compatibility Warning') + ).not.toBeVisible() + }) +}) diff --git a/package-lock.json b/package-lock.json index fa5cee570..5ec43f663 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,15 @@ "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, + "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 +612,15 @@ "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, + "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 +642,15 @@ "@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, + "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", @@ -2422,18 +2451,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@intlify/eslint-plugin-vue-i18n/node_modules/synckit": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.3.tgz", @@ -4523,6 +4540,12 @@ "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 + }, "node_modules/@types/stats.js": { "version": "0.17.3", "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", @@ -4754,19 +4777,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", @@ -6537,19 +6547,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", @@ -7449,19 +7446,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", @@ -7741,18 +7725,6 @@ "eslint": ">=6.0.0" } }, - "node_modules/eslint-compat-utils/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-config-prettier": { "version": "10.1.2", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.2.tgz", @@ -7852,19 +7824,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", @@ -10284,18 +10243,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/jsonc-eslint-parser/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jsondiffpatch": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz", @@ -10726,19 +10673,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", @@ -12688,19 +12622,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", @@ -14343,12 +14264,14 @@ "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==", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -16207,19 +16130,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", @@ -17039,19 +16949,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", @@ -17104,19 +17001,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 2dfd9aa87..b394bc70b 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/components/dialog/content/VersionMismatchWarning.vue b/src/components/dialog/content/VersionMismatchWarning.vue deleted file mode 100644 index e834b23c8..000000000 --- a/src/components/dialog/content/VersionMismatchWarning.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - - - diff --git a/src/composables/useFrontendVersionMismatchWarning.ts b/src/composables/useFrontendVersionMismatchWarning.ts new file mode 100644 index 000000000..11897a016 --- /dev/null +++ b/src/composables/useFrontendVersionMismatchWarning.ts @@ -0,0 +1,94 @@ +import { whenever } from '@vueuse/core' +import { computed, onMounted } from 'vue' +import { useI18n } from 'vue-i18n' + +import { useToastStore } from '@/stores/toastStore' +import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore' + +export interface UseFrontendVersionMismatchWarningOptions { + immediate?: boolean +} + +/** + * Composable for handling frontend version mismatch warnings. + * + * Displays toast notifications when the frontend version is incompatible with the backend, + * either because the frontend is outdated or newer than the backend expects. + * Automatically dismisses warnings when shown and persists dismissal state for 7 days. + * + * @param options - Configuration options + * @param options.immediate - If true, automatically shows warning when version mismatch is detected + * @returns Object with methods and computed properties for managing version warnings + * + * @example + * ```ts + * // Show warning immediately when mismatch detected + * const { showWarning, shouldShowWarning } = useFrontendVersionMismatchWarning({ immediate: true }) + * + * // Manual control + * const { showWarning } = useFrontendVersionMismatchWarning() + * showWarning() // Call when needed + * ``` + */ +export function useFrontendVersionMismatchWarning( + options: UseFrontendVersionMismatchWarningOptions = {} +) { + const { immediate = false } = options + const { t } = useI18n() + const toastStore = useToastStore() + const versionCompatibilityStore = useVersionCompatibilityStore() + + // Track if we've already shown the warning + let hasShownWarning = false + + const showWarning = () => { + // Prevent showing the warning multiple times + if (hasShownWarning) return + + const message = versionCompatibilityStore.warningMessage + if (!message) return + + const detailMessage = t('g.frontendOutdated', { + frontendVersion: message.frontendVersion, + requiredVersion: message.requiredVersion + }) + + const fullMessage = t('g.versionMismatchWarningMessage', { + warning: t('g.versionMismatchWarning'), + detail: detailMessage + }) + + toastStore.addAlert(fullMessage) + hasShownWarning = true + + // Automatically dismiss the warning so it won't show again for 7 days + versionCompatibilityStore.dismissWarning() + } + + onMounted(() => { + // Only set up the watcher if immediate is true + if (immediate) { + whenever( + () => versionCompatibilityStore.shouldShowWarning, + () => { + showWarning() + }, + { + immediate: true, + once: true + } + ) + } + }) + + return { + showWarning, + shouldShowWarning: computed( + () => versionCompatibilityStore.shouldShowWarning + ), + dismissWarning: versionCompatibilityStore.dismissWarning, + hasVersionMismatch: computed( + () => versionCompatibilityStore.hasVersionMismatch + ) + } +} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 46c6a2ded..5fa4aa422 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -98,6 +98,12 @@ "nodes": "Nodes", "community": "Community", "all": "All", + "versionMismatchWarning": "Version Compatibility Warning", + "versionMismatchWarningMessage": "{warning}: {detail} Visit https://docs.comfy.org/installation/update_comfyui#common-update-issues for update instructions.", + "frontendOutdated": "Frontend version {frontendVersion} is outdated. Backend requires {requiredVersion} or higher.", + "frontendNewer": "Frontend version {frontendVersion} may not be compatible with backend version {backendVersion}.", + "updateFrontend": "Update Frontend", + "dismiss": "Dismiss", "update": "Update", "updated": "Updated", "resultsCount": "Found {count} Results", diff --git a/src/stores/versionCompatibilityStore.ts b/src/stores/versionCompatibilityStore.ts index a23ba980f..da483846e 100644 --- a/src/stores/versionCompatibilityStore.ts +++ b/src/stores/versionCompatibilityStore.ts @@ -1,21 +1,17 @@ +import { useStorage } from '@vueuse/core' import { defineStore } from 'pinia' -import { computed, ref } from 'vue' +import * as semver from 'semver' +import { computed } from 'vue' import config from '@/config' -import { useSettingStore } from '@/stores/settingStore' import { useSystemStatsStore } from '@/stores/systemStatsStore' -import { compareVersions, isSemVer } from '@/utils/formatUtil' + +const DISMISSAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days export const useVersionCompatibilityStore = defineStore( 'versionCompatibility', () => { const systemStatsStore = useSystemStatsStore() - const settingStore = useSettingStore() - - const isDismissed = ref(false) - const dismissedVersion = ref( - settingStore.get('Comfy.VersionMismatch.DismissedVersion') ?? null - ) const frontendVersion = computed(() => config.app_version) const backendVersion = computed( @@ -30,47 +26,68 @@ export const useVersionCompatibilityStore = defineStore( if ( !frontendVersion.value || !requiredFrontendVersion.value || - !isSemVer(frontendVersion.value) || - !isSemVer(requiredFrontendVersion.value) + !semver.valid(frontendVersion.value) || + !semver.valid(requiredFrontendVersion.value) ) { return false } - return ( - compareVersions(requiredFrontendVersion.value, frontendVersion.value) > - 0 - ) + // Returns true if required version is greater than frontend version + return semver.gt(requiredFrontendVersion.value, frontendVersion.value) }) const isFrontendNewer = computed(() => { - if ( - !frontendVersion.value || - !backendVersion.value || - !isSemVer(frontendVersion.value) || - !isSemVer(backendVersion.value) - ) { - return false - } - const versionDiff = compareVersions( - frontendVersion.value, - backendVersion.value - ) - return versionDiff > 0 + // We don't warn about frontend being newer than backend + // Only warn when frontend is outdated (behind required version) + return false }) const hasVersionMismatch = computed(() => { - return isFrontendOutdated.value || isFrontendNewer.value + return isFrontendOutdated.value }) - const currentVersionKey = computed( - () => - `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}` + const versionKey = computed(() => { + if ( + !frontendVersion.value || + !backendVersion.value || + !requiredFrontendVersion.value + ) { + return null + } + return `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}` + }) + + // Use reactive storage for dismissals - creates a reactive ref that syncs with localStorage + // All version mismatch dismissals are stored in a single object for clean localStorage organization + const dismissalStorage = useStorage( + 'comfy.versionMismatch.dismissals', + {} as Record, + localStorage, + { + serializer: { + read: (value: string) => { + try { + return JSON.parse(value) + } catch { + return {} + } + }, + write: (value: Record) => JSON.stringify(value) + } + } ) + const isDismissed = computed(() => { + if (!versionKey.value) return false + + const dismissedUntil = dismissalStorage.value[versionKey.value] + if (!dismissedUntil) return false + + // Check if dismissal has expired + return Date.now() < dismissedUntil + }) + const shouldShowWarning = computed(() => { - if (!hasVersionMismatch.value || isDismissed.value) { - return false - } - return dismissedVersion.value !== currentVersionKey.value + return hasVersionMismatch.value && !isDismissed.value }) const warningMessage = computed(() => { @@ -80,12 +97,6 @@ export const useVersionCompatibilityStore = defineStore( frontendVersion: frontendVersion.value, requiredVersion: requiredFrontendVersion.value } - } else if (isFrontendNewer.value) { - return { - type: 'newer' as const, - frontendVersion: frontendVersion.value, - backendVersion: backendVersion.value - } } return null }) @@ -96,29 +107,18 @@ export const useVersionCompatibilityStore = defineStore( } } - async function dismissWarning() { - isDismissed.value = true - dismissedVersion.value = currentVersionKey.value + function dismissWarning() { + if (!versionKey.value) return - await settingStore.set( - 'Comfy.VersionMismatch.DismissedVersion', - currentVersionKey.value - ) - } - - function restoreDismissalState() { - const dismissed = settingStore.get( - 'Comfy.VersionMismatch.DismissedVersion' - ) - if (dismissed) { - dismissedVersion.value = dismissed - isDismissed.value = dismissed === currentVersionKey.value + const dismissUntil = Date.now() + DISMISSAL_DURATION_MS + dismissalStorage.value = { + ...dismissalStorage.value, + [versionKey.value]: dismissUntil } } async function initialize() { await checkVersionCompatibility() - restoreDismissalState() } return { diff --git a/src/views/GraphView.vue b/src/views/GraphView.vue index 2de7eeed7..f8394e0a7 100644 --- a/src/views/GraphView.vue +++ b/src/views/GraphView.vue @@ -9,7 +9,6 @@
-
@@ -24,12 +23,18 @@ import { useBreakpoints, useEventListener } from '@vueuse/core' import type { ToastMessageOptions } from 'primevue/toast' import { useToast } from 'primevue/usetoast' -import { computed, onBeforeUnmount, onMounted, watch, watchEffect } from 'vue' +import { + computed, + nextTick, + onBeforeUnmount, + onMounted, + watch, + watchEffect +} from 'vue' import { useI18n } from 'vue-i18n' import MenuHamburger from '@/components/MenuHamburger.vue' import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue' -import VersionMismatchWarning from '@/components/dialog/content/VersionMismatchWarning.vue' import GraphCanvas from '@/components/graph/GraphCanvas.vue' import GlobalToast from '@/components/toast/GlobalToast.vue' import RerouteMigrationToast from '@/components/toast/RerouteMigrationToast.vue' @@ -37,6 +42,7 @@ import TopMenubar from '@/components/topbar/TopMenubar.vue' import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle' import { useCoreCommands } from '@/composables/useCoreCommands' import { useErrorHandling } from '@/composables/useErrorHandling' +import { useFrontendVersionMismatchWarning } from '@/composables/useFrontendVersionMismatchWarning' import { useProgressFavicon } from '@/composables/useProgressFavicon' import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig' import { i18n } from '@/i18n' @@ -229,6 +235,22 @@ onBeforeUnmount(() => { useEventListener(window, 'keydown', useKeybindingService().keybindHandler) const { wrapWithErrorHandling, wrapWithErrorHandlingAsync } = useErrorHandling() + +// Initialize version mismatch warning in setup context +// It will be triggered automatically when the store is ready +useFrontendVersionMismatchWarning({ immediate: true }) + +// Initialize version compatibility check completely independently of app setup +// This runs asynchronously after component setup and won't block the main application +void nextTick(() => { + // Use setTimeout to ensure this happens after all other immediate tasks + setTimeout(() => { + versionCompatibilityStore.initialize().catch((error) => { + console.warn('Version compatibility check failed:', error) + }) + }, 100) // Small delay to ensure app is fully loaded +}) + const onGraphReady = () => { requestIdleCallback( () => { @@ -254,7 +276,6 @@ const onGraphReady = () => { // Explicitly initialize nodeSearchService to avoid indexing delay when // node search is triggered useNodeDefStore().nodeSearchService.searchNode('') - void versionCompatibilityStore.initialize() }, { timeout: 1000 } ) diff --git a/tests-ui/tests/composables/useFrontendVersionMismatchWarning.test.ts b/tests-ui/tests/composables/useFrontendVersionMismatchWarning.test.ts new file mode 100644 index 000000000..b8b4fceac --- /dev/null +++ b/tests-ui/tests/composables/useFrontendVersionMismatchWarning.test.ts @@ -0,0 +1,234 @@ +import { createPinia, setActivePinia } from 'pinia' +import { vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { nextTick } from 'vue' + +import { useFrontendVersionMismatchWarning } from '@/composables/useFrontendVersionMismatchWarning' +import { useToastStore } from '@/stores/toastStore' +import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore' + +// Mock globals +//@ts-expect-error Define global for the test +global.__COMFYUI_FRONTEND_VERSION__ = '1.0.0' + +// Mock config first - this needs to be before any imports +vi.mock('@/config', () => ({ + default: { + app_title: 'ComfyUI', + app_version: '1.0.0' + } +})) + +// Mock app +vi.mock('@/scripts/app', () => ({ + app: { + ui: { + settings: { + dispatchChange: vi.fn() + } + } + } +})) + +// Mock api +vi.mock('@/scripts/api', () => ({ + api: { + getSettings: vi.fn(() => Promise.resolve({})), + storeSetting: vi.fn(() => Promise.resolve(undefined)) + } +})) + +// Mock vue-i18n +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (key: string, params?: any) => { + if (key === 'g.versionMismatchWarning') + return 'Version Compatibility Warning' + if (key === 'g.versionMismatchWarningMessage' && params) { + return `${params.warning}: ${params.detail} Visit https://docs.comfy.org/installation/update_comfyui#common-update-issues for update instructions.` + } + if (key === 'g.frontendOutdated' && params) { + return `Frontend version ${params.frontendVersion} is outdated. Backend requires ${params.requiredVersion} or higher.` + } + if (key === 'g.frontendNewer' && params) { + return `Frontend version ${params.frontendVersion} may not be compatible with backend version ${params.backendVersion}.` + } + return key + } + }), + createI18n: vi.fn(() => ({ + global: { + locale: { value: 'en' }, + t: vi.fn() + } + })) +})) + +// Mock lifecycle hooks to track their calls +const mockOnMounted = vi.fn() +vi.mock('vue', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + onMounted: (fn: () => void) => { + mockOnMounted() + fn() + } + } +}) + +describe('useFrontendVersionMismatchWarning', () => { + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should not show warning when there is no version mismatch', () => { + const toastStore = useToastStore() + const versionStore = useVersionCompatibilityStore() + const addAlertSpy = vi.spyOn(toastStore, 'addAlert') + + // Mock no version mismatch + vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(false) + + useFrontendVersionMismatchWarning() + + expect(addAlertSpy).not.toHaveBeenCalled() + }) + + it('should show warning immediately when immediate option is true and there is a mismatch', async () => { + const toastStore = useToastStore() + const versionStore = useVersionCompatibilityStore() + const addAlertSpy = vi.spyOn(toastStore, 'addAlert') + const dismissWarningSpy = vi.spyOn(versionStore, 'dismissWarning') + + // Mock version mismatch + vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(true) + vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({ + type: 'outdated', + frontendVersion: '1.0.0', + requiredVersion: '2.0.0' + }) + + useFrontendVersionMismatchWarning({ immediate: true }) + + // For immediate: true, the watcher should fire immediately in onMounted + await nextTick() + + expect(addAlertSpy).toHaveBeenCalledWith( + expect.stringContaining('Version Compatibility Warning') + ) + expect(addAlertSpy).toHaveBeenCalledWith( + expect.stringContaining('Frontend version 1.0.0 is outdated') + ) + // Should automatically dismiss the warning + expect(dismissWarningSpy).toHaveBeenCalled() + }) + + it('should not show warning immediately when immediate option is false', async () => { + const toastStore = useToastStore() + const versionStore = useVersionCompatibilityStore() + const addAlertSpy = vi.spyOn(toastStore, 'addAlert') + + // Mock version mismatch + vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(true) + vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({ + type: 'outdated', + frontendVersion: '1.0.0', + requiredVersion: '2.0.0' + }) + + const result = useFrontendVersionMismatchWarning({ immediate: false }) + await nextTick() + + // Should not show automatically + expect(addAlertSpy).not.toHaveBeenCalled() + + // But should show when called manually + result.showWarning() + expect(addAlertSpy).toHaveBeenCalledOnce() + }) + + it('should call showWarning method manually', () => { + const toastStore = useToastStore() + const versionStore = useVersionCompatibilityStore() + const addAlertSpy = vi.spyOn(toastStore, 'addAlert') + const dismissWarningSpy = vi.spyOn(versionStore, 'dismissWarning') + + vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({ + type: 'outdated', + frontendVersion: '1.0.0', + requiredVersion: '2.0.0' + }) + + const { showWarning } = useFrontendVersionMismatchWarning() + showWarning() + + expect(addAlertSpy).toHaveBeenCalledOnce() + expect(dismissWarningSpy).toHaveBeenCalled() + }) + + it('should expose store methods and computed values', () => { + const versionStore = useVersionCompatibilityStore() + + const mockDismissWarning = vi.fn() + vi.spyOn(versionStore, 'dismissWarning').mockImplementation( + mockDismissWarning + ) + vi.spyOn(versionStore, 'shouldShowWarning', 'get').mockReturnValue(true) + vi.spyOn(versionStore, 'hasVersionMismatch', 'get').mockReturnValue(true) + + const result = useFrontendVersionMismatchWarning() + + expect(result.shouldShowWarning.value).toBe(true) + expect(result.hasVersionMismatch.value).toBe(true) + + void result.dismissWarning() + expect(mockDismissWarning).toHaveBeenCalled() + }) + + it('should register onMounted hook', () => { + useFrontendVersionMismatchWarning() + + expect(mockOnMounted).toHaveBeenCalledOnce() + }) + + it('should not show warning when warningMessage is null', () => { + const toastStore = useToastStore() + const versionStore = useVersionCompatibilityStore() + const addAlertSpy = vi.spyOn(toastStore, 'addAlert') + + vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue(null) + + const { showWarning } = useFrontendVersionMismatchWarning() + showWarning() + + expect(addAlertSpy).not.toHaveBeenCalled() + }) + + it('should only show warning once even if called multiple times', () => { + const toastStore = useToastStore() + const versionStore = useVersionCompatibilityStore() + const addAlertSpy = vi.spyOn(toastStore, 'addAlert') + + vi.spyOn(versionStore, 'warningMessage', 'get').mockReturnValue({ + type: 'outdated', + frontendVersion: '1.0.0', + requiredVersion: '2.0.0' + }) + + const { showWarning } = useFrontendVersionMismatchWarning() + + // Call showWarning multiple times + showWarning() + showWarning() + showWarning() + + // Should only have been called once + expect(addAlertSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests-ui/tests/store/versionCompatibilityStore.test.ts b/tests-ui/tests/store/versionCompatibilityStore.test.ts index 4f72f6230..e3d3ceca9 100644 --- a/tests-ui/tests/store/versionCompatibilityStore.test.ts +++ b/tests-ui/tests/store/versionCompatibilityStore.test.ts @@ -1,7 +1,7 @@ import { createPinia, setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' -import { useSettingStore } from '@/stores/settingStore' import { useSystemStatsStore } from '@/stores/systemStatsStore' import { useVersionCompatibilityStore } from '@/stores/versionCompatibilityStore' @@ -12,32 +12,37 @@ vi.mock('@/config', () => ({ })) vi.mock('@/stores/systemStatsStore') -vi.mock('@/stores/settingStore') + +// Mock useStorage from VueUse +const mockDismissalStorage = ref({} as Record) +vi.mock('@vueuse/core', () => ({ + useStorage: vi.fn(() => mockDismissalStorage) +})) describe('useVersionCompatibilityStore', () => { let store: ReturnType let mockSystemStatsStore: any - let mockSettingStore: any beforeEach(() => { setActivePinia(createPinia()) + // Clear the mock dismissal storage + mockDismissalStorage.value = {} + mockSystemStatsStore = { systemStats: null, fetchSystemStats: vi.fn() } - mockSettingStore = { - get: vi.fn(), - set: vi.fn() - } - vi.mocked(useSystemStatsStore).mockReturnValue(mockSystemStatsStore) - vi.mocked(useSettingStore).mockReturnValue(mockSettingStore) store = useVersionCompatibilityStore() }) + afterEach(() => { + vi.clearAllMocks() + }) + describe('version compatibility detection', () => { it('should detect frontend is outdated when required version is higher', async () => { mockSystemStatsStore.systemStats = { @@ -54,7 +59,9 @@ describe('useVersionCompatibilityStore', () => { expect(store.hasVersionMismatch).toBe(true) }) - it('should detect frontend is newer when frontend version is higher than backend', async () => { + it('should not warn when frontend is newer than backend', async () => { + // Frontend: 1.24.0, Backend: 1.23.0, Required: 1.23.0 + // Frontend meets required version, no warning needed mockSystemStatsStore.systemStats = { system: { comfyui_version: '1.23.0', @@ -65,8 +72,8 @@ describe('useVersionCompatibilityStore', () => { await store.checkVersionCompatibility() expect(store.isFrontendOutdated).toBe(false) - expect(store.isFrontendNewer).toBe(true) - expect(store.hasVersionMismatch).toBe(true) + expect(store.isFrontendNewer).toBe(false) + expect(store.hasVersionMismatch).toBe(false) }) it('should not detect mismatch when versions are compatible', async () => { @@ -103,7 +110,7 @@ describe('useVersionCompatibilityStore', () => { mockSystemStatsStore.systemStats = { system: { comfyui_version: '080e6d4af809a46852d1c4b7ed85f06e8a3a72be', // git hash - required_frontend_version: '1.29.2.45' // invalid semver + required_frontend_version: 'not-a-version' // invalid semver format } } @@ -113,14 +120,28 @@ describe('useVersionCompatibilityStore', () => { expect(store.isFrontendNewer).toBe(false) expect(store.hasVersionMismatch).toBe(false) }) + + it('should not warn when frontend exceeds required version', async () => { + // Frontend: 1.24.0 (from mock config) + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.22.0', // Backend is older + required_frontend_version: '1.23.0' // Required is 1.23.0, frontend 1.24.0 meets this + } + } + + await store.checkVersionCompatibility() + + expect(store.isFrontendOutdated).toBe(false) // Frontend 1.24.0 >= Required 1.23.0 + expect(store.isFrontendNewer).toBe(false) // Never warns about being newer + expect(store.hasVersionMismatch).toBe(false) + }) }) describe('warning display logic', () => { - beforeEach(() => { - mockSettingStore.get.mockReturnValue('') - }) - it('should show warning when there is a version mismatch and not dismissed', async () => { + // No dismissals in storage + mockDismissalStorage.value = {} mockSystemStatsStore.systemStats = { system: { comfyui_version: '1.25.0', @@ -134,6 +155,12 @@ describe('useVersionCompatibilityStore', () => { }) it('should not show warning when dismissed', async () => { + const futureTime = Date.now() + 1000000 + // Set dismissal in reactive storage + mockDismissalStorage.value = { + '1.24.0-1.25.0-1.25.0': futureTime + } + mockSystemStatsStore.systemStats = { system: { comfyui_version: '1.25.0', @@ -142,7 +169,6 @@ describe('useVersionCompatibilityStore', () => { } await store.checkVersionCompatibility() - void store.dismissWarning() expect(store.shouldShowWarning).toBe(false) }) @@ -179,23 +205,6 @@ describe('useVersionCompatibilityStore', () => { }) }) - it('should generate newer message when frontend is newer', async () => { - mockSystemStatsStore.systemStats = { - system: { - comfyui_version: '1.23.0', - required_frontend_version: '1.23.0' - } - } - - await store.checkVersionCompatibility() - - expect(store.warningMessage).toEqual({ - type: 'newer', - frontendVersion: '1.24.0', - backendVersion: '1.23.0' - }) - }) - it('should return null when no mismatch', async () => { mockSystemStatsStore.systemStats = { system: { @@ -211,7 +220,10 @@ describe('useVersionCompatibilityStore', () => { }) describe('dismissal persistence', () => { - it('should save dismissal to settings', async () => { + it('should save dismissal to reactive storage with expiration', async () => { + const mockNow = 1000000 + vi.spyOn(Date, 'now').mockReturnValue(mockNow) + mockSystemStatsStore.systemStats = { system: { comfyui_version: '1.25.0', @@ -220,16 +232,20 @@ describe('useVersionCompatibilityStore', () => { } await store.checkVersionCompatibility() - await store.dismissWarning() + store.dismissWarning() - expect(mockSettingStore.set).toHaveBeenCalledWith( - 'Comfy.VersionMismatch.DismissedVersion', - '1.24.0-1.25.0-1.25.0' - ) + // Check that the dismissal was added to reactive storage + expect(mockDismissalStorage.value).toEqual({ + '1.24.0-1.25.0-1.25.0': mockNow + 7 * 24 * 60 * 60 * 1000 + }) }) - it('should restore dismissal state from settings', async () => { - mockSettingStore.get.mockReturnValue('1.24.0-1.25.0-1.25.0') + it('should check dismissal state from reactive storage', async () => { + const futureTime = Date.now() + 1000000 // Still valid + mockDismissalStorage.value = { + '1.24.0-1.25.0-1.25.0': futureTime + } + mockSystemStatsStore.systemStats = { system: { comfyui_version: '1.25.0', @@ -242,8 +258,31 @@ describe('useVersionCompatibilityStore', () => { expect(store.shouldShowWarning).toBe(false) }) + it('should show warning if dismissal has expired', async () => { + const pastTime = Date.now() - 1000 // Expired + mockDismissalStorage.value = { + '1.24.0-1.25.0-1.25.0': pastTime + } + + mockSystemStatsStore.systemStats = { + system: { + comfyui_version: '1.25.0', + required_frontend_version: '1.25.0' + } + } + + await store.initialize() + + expect(store.shouldShowWarning).toBe(true) + }) + it('should show warning for different version combinations even if previous was dismissed', async () => { - mockSettingStore.get.mockReturnValue('1.24.0-1.25.0-1.25.0') + const futureTime = Date.now() + 1000000 + // Dismissed for different version combination (1.25.0) but current is 1.26.0 + mockDismissalStorage.value = { + '1.24.0-1.25.0-1.25.0': futureTime // Different version was dismissed + } + mockSystemStatsStore.systemStats = { system: { comfyui_version: '1.26.0',